This commit is contained in:
Bastian Wagner
2026-07-17 15:34:42 +02:00
parent e89ac1481e
commit 5973658582
16 changed files with 768 additions and 11 deletions

View File

@@ -11,6 +11,7 @@ import { PasswordModule } from './password/password.module';
import { RegistrationModule } from './registration/registration.module';
import { AccountModule } from './account/account.module';
import { ApplicationErrorLogModule } from './application-error-log/application-error-log.module';
import { ApplicationInfoLogModule } from './application-info-log/application-info-log.module';
@Module({
imports: [
@@ -54,6 +55,7 @@ import { ApplicationErrorLogModule } from './application-error-log/application-e
},
}),
ApplicationErrorLogModule,
ApplicationInfoLogModule,
AuditModule,
AdminModule,
MailModule,

View File

@@ -0,0 +1,84 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
@Entity({ name: 'application_info_logs' })
@Index(['createdAt'])
@Index(['action'])
@Index(['category'])
@Index(['outcome'])
@Index(['correlationId'])
@Index(['userId'])
@Index(['clientId'])
@Index(['tenantId'])
export class ApplicationInfoLog {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ default: 'info' })
level!: string;
@Column()
action!: string;
@Column({ nullable: true })
category?: string;
@Column({ default: 'SUCCESS' })
outcome!: string;
@Column({ type: 'text', nullable: true })
message?: string;
@Column({ nullable: true })
actorType?: string;
@Column({ nullable: true })
actorId?: string;
@Column({ nullable: true })
userId?: string;
@Column({ nullable: true })
clientId?: string;
@Column({ nullable: true })
tenantId?: string;
@Column({ nullable: true })
backendModule?: string;
@Column({ nullable: true })
service?: string;
@Column({ nullable: true })
operation?: string;
@Column({ nullable: true })
httpMethod?: string;
@Column({ nullable: true })
apiPath?: string;
@Column({ type: 'int', nullable: true })
httpStatusCode?: number;
@Column({ nullable: true })
correlationId?: string;
@Column({ nullable: true })
ipAddress?: string;
@Column({ type: 'text', nullable: true })
userAgent?: string;
@Column({ nullable: true })
environment?: string;
@Column({ nullable: true })
host?: string;
@Column({ type: 'simple-json', nullable: true })
context?: Record<string, unknown>;
@CreateDateColumn()
createdAt!: Date;
}

View File

@@ -0,0 +1,18 @@
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationInfoLog } from './application-info-log.entity';
import { ApplicationInfoLoggerService } from './application-info-logger.service';
import { ApplicationInfoInterceptor } from './application-info.interceptor';
@Global()
@Module({
imports: [ConfigModule, TypeOrmModule.forFeature([ApplicationInfoLog])],
providers: [
ApplicationInfoLoggerService,
{ provide: APP_INTERCEPTOR, useClass: ApplicationInfoInterceptor },
],
exports: [ApplicationInfoLoggerService],
})
export class ApplicationInfoLogModule {}

View File

@@ -0,0 +1,26 @@
export type ApplicationInfoActorType = 'USER' | 'CLIENT' | 'SYSTEM';
export type ApplicationInfoOutcome = 'SUCCESS' | 'FAILURE' | 'STARTED' | 'SKIPPED';
export interface ApplicationInfoLogInput {
action: string;
category?: string;
outcome?: ApplicationInfoOutcome;
message?: string;
actorType?: ApplicationInfoActorType;
actorId?: string;
userId?: string;
clientId?: string;
tenantId?: string;
backendModule?: string;
service?: string;
operation?: string;
requestContext?: {
correlationId?: string;
method?: string;
path?: string;
statusCode?: number;
ipAddress?: string;
userAgent?: string;
};
context?: Record<string, unknown>;
}

View File

@@ -0,0 +1,80 @@
import { Logger } from '@nestjs/common';
import { ApplicationInfoLoggerService } from './application-info-logger.service';
describe('ApplicationInfoLoggerService', () => {
function createService(save = jest.fn().mockResolvedValue(undefined)) {
const repository = {
create: jest.fn((value) => value),
save,
};
const config = { get: jest.fn().mockReturnValue('test') };
const requestContext = {
get: jest.fn().mockReturnValue({
correlationId: 'correlation-id',
method: 'POST',
path: '/auth/login',
tenantId: 'tenant-id',
}),
};
return {
service: new ApplicationInfoLoggerService(repository as any, config as any, requestContext as any),
repository,
};
}
it('stores a structured action with request and actor context', async () => {
const { service, repository } = createService();
await service.log({
action: 'auth.login_success',
category: 'AUTH',
actorType: 'USER',
actorId: 'maria',
userId: 'maria',
requestContext: { ipAddress: '192.0.2.10', userAgent: 'Test Browser' },
});
expect(repository.save).toHaveBeenCalledWith(expect.objectContaining({
action: 'auth.login_success',
category: 'AUTH',
outcome: 'SUCCESS',
actorType: 'USER',
actorId: 'maria',
userId: 'maria',
correlationId: 'correlation-id',
tenantId: 'tenant-id',
httpMethod: 'POST',
apiPath: '/auth/login',
ipAddress: '192.0.2.10',
environment: 'test',
}));
});
it('removes sensitive nested context and masks email addresses', async () => {
const { service, repository } = createService();
await service.log({
action: 'registration.started',
actorType: 'USER',
actorId: 'maria@example.com',
context: {
email: 'maria@example.com',
nested: { password: 'do-not-store', clientSecret: 'do-not-store', value: 'allowed' },
},
});
const stored = repository.save.mock.calls[0][0];
expect(stored.actorId).toBe('m***@example.com');
expect(stored.context.email).toBe('m***@example.com');
expect(stored.context.nested).toEqual({ value: 'allowed' });
});
it('does not throw when persistence fails', async () => {
jest.spyOn(Logger.prototype, 'error').mockImplementation();
const { service } = createService(jest.fn().mockRejectedValue(new Error('database unavailable')));
await expect(service.log({ action: 'auth.login_success' })).resolves.toBeUndefined();
expect(Logger.prototype.error).toHaveBeenCalled();
jest.restoreAllMocks();
});
});

View File

@@ -0,0 +1,69 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { hostname } from 'node:os';
import { Repository } from 'typeorm';
import { sanitizeContext, sanitizeString } from '../application-error-log/application-error-sanitizer';
import { RequestContextService } from '../common/request-context.service';
import { ApplicationInfoLog } from './application-info-log.entity';
import { ApplicationInfoLogInput } from './application-info-log.types';
@Injectable()
export class ApplicationInfoLoggerService {
private readonly fallbackLogger = new Logger(ApplicationInfoLoggerService.name);
constructor(
@InjectRepository(ApplicationInfoLog)
private readonly logs: Repository<ApplicationInfoLog>,
private readonly config: ConfigService,
private readonly requestContext: RequestContextService,
) {}
async log(input: ApplicationInfoLogInput): Promise<void> {
const activeRequest = this.requestContext.get();
const request = { ...activeRequest, ...input.requestContext };
try {
await this.logs.save(
this.logs.create({
level: 'info',
action: sanitizeString(input.action),
category: optionalString(input.category),
outcome: input.outcome ?? 'SUCCESS',
message: optionalString(input.message),
actorType: input.actorType,
actorId: optionalString(input.actorId),
userId: optionalString(input.userId ?? request.userId),
clientId: optionalString(input.clientId),
tenantId: optionalString(input.tenantId ?? request.tenantId),
backendModule: optionalString(input.backendModule),
service: optionalString(input.service),
operation: optionalString(input.operation),
httpMethod: optionalString(request.method),
apiPath: optionalString(withoutQuery(request.path)),
httpStatusCode: input.requestContext?.statusCode,
correlationId: optionalString(request.correlationId),
ipAddress: optionalString(input.requestContext?.ipAddress),
userAgent: optionalString(input.requestContext?.userAgent),
environment: this.config.get<string>('NODE_ENV') ?? 'development',
host: hostname(),
context: sanitizeContext(input.context),
}),
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.fallbackLogger.error(
`Application info log write failed for ${sanitizeString(input.action)}: ${sanitizeString(message)}`,
error instanceof Error ? error.stack : undefined,
);
}
}
}
function optionalString(value?: string): string | undefined {
return value ? sanitizeString(value) : undefined;
}
function withoutQuery(value?: string): string | undefined {
return value?.split('?', 1)[0];
}

View File

@@ -0,0 +1,61 @@
import { BadRequestException, CallHandler, ExecutionContext } from '@nestjs/common';
import { lastValueFrom, of, throwError } from 'rxjs';
import { ApplicationInfoInterceptor } from './application-info.interceptor';
describe('ApplicationInfoInterceptor', () => {
function createContext(path = '/admin/users') {
const request = {
method: 'GET',
path,
originalUrl: `${path}?token=do-not-store`,
route: { path: '/admin/users' },
ip: '192.0.2.10',
headers: { 'user-agent': 'Test Browser', authorization: 'Bearer secret' },
user: { sub: 'user-id', username: 'maria' },
body: { password: 'do-not-store' },
};
const response = { statusCode: 200, getHeader: jest.fn().mockReturnValue('application/json') };
const context = {
getType: jest.fn().mockReturnValue('http'),
switchToHttp: jest.fn().mockReturnValue({
getRequest: () => request,
getResponse: () => response,
}),
} as unknown as ExecutionContext;
return { context, request, response };
}
it('logs a successful request without body, query or authorization data', async () => {
const infoLogger = { log: jest.fn().mockResolvedValue(undefined) };
const interceptor = new ApplicationInfoInterceptor(infoLogger as any);
const { context } = createContext();
const next = { handle: () => of({ ok: true }) } as CallHandler;
await lastValueFrom(interceptor.intercept(context, next));
expect(infoLogger.log).toHaveBeenCalledWith(expect.objectContaining({
action: 'http.request_completed',
outcome: 'SUCCESS',
actorType: 'USER',
actorId: 'maria',
requestContext: expect.objectContaining({ path: '/admin/users', statusCode: 200 }),
}));
expect(JSON.stringify(infoLogger.log.mock.calls[0][0])).not.toContain('do-not-store');
expect(JSON.stringify(infoLogger.log.mock.calls[0][0])).not.toContain('Bearer');
});
it('logs a failed request with its controlled HTTP status', async () => {
const infoLogger = { log: jest.fn().mockResolvedValue(undefined) };
const interceptor = new ApplicationInfoInterceptor(infoLogger as any);
const { context } = createContext('/admin/users/invalid');
const next = { handle: () => throwError(() => new BadRequestException('invalid')) } as CallHandler;
await expect(lastValueFrom(interceptor.intercept(context, next))).rejects.toBeInstanceOf(BadRequestException);
expect(infoLogger.log).toHaveBeenCalledWith(expect.objectContaining({
action: 'http.request_failed',
outcome: 'FAILURE',
requestContext: expect.objectContaining({ statusCode: 400 }),
}));
});
});

View File

@@ -0,0 +1,66 @@
import { CallHandler, ExecutionContext, HttpException, Injectable, NestInterceptor } from '@nestjs/common';
import { Request, Response } from 'express';
import { Observable, tap } from 'rxjs';
import { RequestUser } from '../common/request-user';
import { ApplicationInfoLoggerService } from './application-info-logger.service';
@Injectable()
export class ApplicationInfoInterceptor implements NestInterceptor {
constructor(private readonly infoLogger: ApplicationInfoLoggerService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
if (context.getType() !== 'http') {
return next.handle();
}
const request = context.switchToHttp().getRequest<Request & { user?: RequestUser }>();
const response = context.switchToHttp().getResponse<Response>();
return next.handle().pipe(
tap({
next: () => this.logRequest(request, response, 'SUCCESS', response.statusCode),
error: (error: unknown) =>
this.logRequest(
request,
response,
'FAILURE',
error instanceof HttpException
? error.getStatus()
: response.statusCode >= 400
? response.statusCode
: 500,
),
}),
);
}
private logRequest(
request: Request & { user?: RequestUser },
response: Response,
outcome: 'SUCCESS' | 'FAILURE',
statusCode: number,
): void {
void this.infoLogger.log({
action: outcome === 'SUCCESS' ? 'http.request_completed' : 'http.request_failed',
category: 'HTTP',
outcome,
actorType: request.user ? 'USER' : 'SYSTEM',
actorId: request.user?.username,
userId: request.user?.sub ?? request.user?.username,
backendModule: 'HttpModule',
service: ApplicationInfoInterceptor.name,
operation: `${request.method} ${request.route?.path ?? request.path}`,
requestContext: {
method: request.method,
path: request.path,
statusCode,
ipAddress: request.ip,
userAgent: request.headers['user-agent'],
},
context: {
route: request.route?.path,
contentType: response.getHeader('content-type'),
},
});
}
}

View File

@@ -0,0 +1,82 @@
import { AuditService } from './audit.service';
describe('AuditService application info bridge', () => {
function createService() {
const repository = {
create: jest.fn((value) => value),
save: jest.fn().mockResolvedValue(undefined),
};
const infoLogger = { log: jest.fn().mockResolvedValue(undefined) };
return {
service: new AuditService(repository as any, infoLogger as any),
repository,
infoLogger,
};
}
it('keeps the existing audit event and logs a user login', async () => {
const { service, repository, infoLogger } = createService();
await service.record({
type: 'auth.login_success',
username: 'maria',
ipAddress: '192.0.2.10',
userAgent: 'Test Browser',
});
expect(repository.save).toHaveBeenCalled();
expect(infoLogger.log).toHaveBeenCalledWith(expect.objectContaining({
action: 'auth.login_success',
category: 'AUTH',
outcome: 'SUCCESS',
actorType: 'USER',
actorId: 'maria',
userId: 'maria',
}));
});
it('identifies a successful OIDC client login', async () => {
const { service, infoLogger } = createService();
await service.record({
type: 'oidc.client_login_success',
username: 'user-id',
metadata: { clientId: 'client-id', scope: 'openid profile' },
});
expect(infoLogger.log).toHaveBeenCalledWith(expect.objectContaining({
action: 'oidc.client_login_success',
category: 'OIDC',
actorType: 'CLIENT',
actorId: 'client-id',
clientId: 'client-id',
userId: 'user-id',
}));
});
it('uses the administrator as actor and the affected user as target', async () => {
const { service, infoLogger } = createService();
await service.record({
type: 'admin.user_updated',
username: 'target-user',
metadata: { admin: 'administrator' },
});
expect(infoLogger.log).toHaveBeenCalledWith(expect.objectContaining({
actorType: 'USER',
actorId: 'administrator',
userId: 'target-user',
}));
});
it('marks failed and started actions with distinct outcomes', async () => {
const { service, infoLogger } = createService();
await service.record({ type: 'auth.login_failed', username: 'maria' });
await service.record({ type: 'registration.started', username: 'maria' });
expect(infoLogger.log).toHaveBeenNthCalledWith(1, expect.objectContaining({ outcome: 'FAILURE' }));
expect(infoLogger.log).toHaveBeenNthCalledWith(2, expect.objectContaining({ outcome: 'STARTED' }));
});
});

View File

@@ -1,6 +1,8 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ApplicationInfoLoggerService } from '../application-info-log/application-info-logger.service';
import { ApplicationInfoActorType, ApplicationInfoOutcome } from '../application-info-log/application-info-log.types';
import { AuditEvent } from './audit-event.entity';
export interface AuditInput {
@@ -16,6 +18,7 @@ export class AuditService {
constructor(
@InjectRepository(AuditEvent)
private readonly events: Repository<AuditEvent>,
private readonly infoLogger: ApplicationInfoLoggerService,
) {}
async record(input: AuditInput): Promise<void> {
@@ -25,5 +28,69 @@ export class AuditService {
metadata: input.metadata ?? {},
}),
);
await this.infoLogger.log(this.toApplicationInfo(input));
}
private toApplicationInfo(input: AuditInput) {
const metadata = input.metadata ?? {};
const clientId = stringValue(metadata.clientId);
const admin = stringValue(metadata.admin);
const actorType = this.actorType(input.type, input.username, clientId, admin);
const actorId = admin ?? (actorType === 'CLIENT' ? clientId : input.username);
return {
action: input.type,
category: input.type.split('.', 1)[0]?.toUpperCase() || 'APPLICATION',
outcome: this.outcome(input.type),
actorType,
actorId,
userId: input.username,
clientId,
backendModule: `${titleCase(input.type.split('.', 1)[0] || 'application')}Module`,
service: AuditService.name,
operation: input.type,
requestContext: {
ipAddress: input.ipAddress,
userAgent: input.userAgent,
},
context: metadata,
};
}
private actorType(type: string, username?: string, clientId?: string, admin?: string): ApplicationInfoActorType {
if (admin || username && !this.isClientAction(type, clientId)) {
return 'USER';
}
return this.isClientAction(type, clientId) ? 'CLIENT' : 'SYSTEM';
}
private isClientAction(type: string, clientId?: string): boolean {
return Boolean(
clientId &&
/^oidc\.(client_login|authorization|access_token|grant|interaction|logout|end_session)/.test(type),
);
}
private outcome(type: string): ApplicationInfoOutcome {
if (type.includes('failed') || type.includes('.error')) {
return 'FAILURE';
}
if (type.endsWith('started') || type.endsWith('requested')) {
return 'STARTED';
}
if (type.endsWith('skipped')) {
return 'SKIPPED';
}
return 'SUCCESS';
}
}
function stringValue(value: unknown): string | undefined {
return typeof value === 'string' && value ? value : undefined;
}
function titleCase(value: string): string {
return value ? `${value[0].toUpperCase()}${value.slice(1)}` : 'Application';
}

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, HttpCode, Post, Req, UseGuards } from '@nestjs/common';
import { Request } from 'express';
import { RequestUser } from '../common/request-user';
import { AuthService } from './auth.service';
@@ -19,6 +19,13 @@ export class AuthController {
);
}
@UseGuards(JwtAuthGuard)
@Post('logout')
@HttpCode(204)
async logout(@Req() request: Request & { user: RequestUser }): Promise<void> {
await this.auth.logout(request.user.username, request.ip, request.headers['user-agent']);
}
@UseGuards(JwtAuthGuard)
@Get('me')
me(@Req() request: Request & { user: RequestUser }) {

View File

@@ -24,4 +24,8 @@ export class AuthService {
user: { username },
};
}
async logout(username: string, ipAddress?: string, userAgent?: string): Promise<void> {
await this.audit.record({ type: 'auth.logout_success', username, ipAddress, userAgent });
}
}

View File

@@ -0,0 +1,59 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
export class CreateApplicationInfoLogs1721300000000 implements MigrationInterface {
name = 'CreateApplicationInfoLogs1721300000000';
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'application_info_logs',
columns: [
{ name: 'id', type: 'varchar', length: '36', isPrimary: true },
{ name: 'level', type: 'varchar', length: '255', default: "'info'" },
{ name: 'action', type: 'varchar', length: '255' },
{ name: 'category', type: 'varchar', length: '255', isNullable: true },
{ name: 'outcome', type: 'varchar', length: '255', default: "'SUCCESS'" },
{ name: 'message', type: 'text', isNullable: true },
{ name: 'actorType', type: 'varchar', length: '255', isNullable: true },
{ name: 'actorId', type: 'varchar', length: '255', isNullable: true },
{ name: 'userId', type: 'varchar', length: '255', isNullable: true },
{ name: 'clientId', type: 'varchar', length: '255', isNullable: true },
{ name: 'tenantId', type: 'varchar', length: '255', isNullable: true },
{ name: 'backendModule', type: 'varchar', length: '255', isNullable: true },
{ name: 'service', type: 'varchar', length: '255', isNullable: true },
{ name: 'operation', type: 'varchar', length: '255', isNullable: true },
{ name: 'httpMethod', type: 'varchar', length: '255', isNullable: true },
{ name: 'apiPath', type: 'varchar', length: '255', isNullable: true },
{ name: 'httpStatusCode', type: 'int', isNullable: true },
{ name: 'correlationId', type: 'varchar', length: '255', isNullable: true },
{ name: 'ipAddress', type: 'varchar', length: '255', isNullable: true },
{ name: 'userAgent', type: 'text', isNullable: true },
{ name: 'environment', type: 'varchar', length: '255', isNullable: true },
{ name: 'host', type: 'varchar', length: '255', isNullable: true },
{ name: 'context', type: 'text', isNullable: true },
{ name: 'createdAt', type: 'datetime', precision: 6, default: 'CURRENT_TIMESTAMP(6)' },
],
}),
);
for (const columnName of [
'createdAt',
'action',
'category',
'outcome',
'correlationId',
'userId',
'clientId',
'tenantId',
]) {
await queryRunner.createIndex(
'application_info_logs',
new TableIndex({ name: `IDX_application_info_logs_${columnName}`, columnNames: [columnName] }),
);
}
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('application_info_logs');
}
}

View File

@@ -74,15 +74,28 @@ export class OidcProviderService implements OnModuleInit {
}
const valid = await this.ldapAuth.verifyPassword(username, password);
const clientId = String(details.params.client_id ?? '');
if (!valid) {
await this.audit.record({ type: 'oidc.login_failed', username, ipAddress: request.ip, userAgent: request.headers['user-agent'] });
await this.audit.record({
type: 'oidc.login_failed',
username,
ipAddress: request.ip,
userAgent: request.headers['user-agent'],
metadata: { clientId },
});
throw new UnauthorizedException('Ungueltige Zugangsdaten.');
}
const account = await this.lldap.getAccount(username);
const subject = account.uuid || account.id;
await this.subjects.save(this.subjects.create({ subject, username: account.id }));
await this.audit.record({ type: 'oidc.login_success', username: account.id, ipAddress: request.ip, userAgent: request.headers['user-agent'] });
await this.audit.record({
type: 'oidc.login_success',
username: account.id,
ipAddress: request.ip,
userAgent: request.headers['user-agent'],
metadata: { clientId },
});
await this.getProvider().interactionFinished(
request,
@@ -144,6 +157,7 @@ export class OidcProviderService implements OnModuleInit {
}
async abortInteraction(request: Request, response: Response): Promise<void> {
const details = await this.interactionDetails(request, response);
await this.getProvider().interactionFinished(
request,
response,
@@ -153,6 +167,16 @@ export class OidcProviderService implements OnModuleInit {
},
{ mergeWithLastSubmission: false },
);
await this.audit.record({
type: 'oidc.interaction_aborted',
username: details.session?.accountId,
ipAddress: request.ip,
userAgent: request.headers['user-agent'],
metadata: {
clientId: String(details.params.client_id ?? ''),
prompt: details.prompt.name,
},
});
}
async isFirstPartyClient(clientId: string): Promise<boolean> {
@@ -284,19 +308,93 @@ export class OidcProviderService implements OnModuleInit {
provider.on('access_token.issued', (token) => {
void this.audit.record({ type: 'oidc.access_token_issued', username: token.accountId, metadata: { clientId: token.clientId } });
});
provider.on('interaction.started', (interaction) => {
provider.on('interaction.started', (ctx, interaction) => {
const activity = this.oidcActivityContext(ctx);
void this.audit.record({
type: 'oidc.interaction_started',
username: interaction?.session?.accountId,
username: activity.userId,
ipAddress: activity.ipAddress,
userAgent: activity.userAgent,
metadata: {
uid: interaction?.uid,
prompt: interaction?.prompt?.name,
clientId: interaction?.params?.client_id,
redirectUri: interaction?.params?.redirect_uri,
scope: interaction?.params?.scope,
uid: activity.interactionId,
prompt: interaction?.name,
clientId: activity.clientId,
redirectUri: activity.redirectUri,
scope: activity.scope,
},
});
});
provider.on('authorization.success', (ctx) => {
const activity = this.oidcActivityContext(ctx);
void this.audit.record({
type: 'oidc.client_login_success',
username: activity.userId,
ipAddress: activity.ipAddress,
userAgent: activity.userAgent,
metadata: {
clientId: activity.clientId,
redirectUri: activity.redirectUri,
responseType: activity.responseType,
scope: activity.scope,
},
});
});
provider.on('grant.success', (ctx) => {
const activity = this.oidcActivityContext(ctx);
void this.audit.record({
type: 'oidc.client_token_granted',
username: activity.userId,
ipAddress: activity.ipAddress,
userAgent: activity.userAgent,
metadata: { clientId: activity.clientId, grantType: activity.grantType },
});
});
provider.on('end_session.success', (ctx) => {
const activity = this.oidcActivityContext(ctx);
void this.audit.record({
type: 'oidc.logout_success',
username: activity.userId,
ipAddress: activity.ipAddress,
userAgent: activity.userAgent,
metadata: { clientId: activity.clientId },
});
});
}
private oidcActivityContext(ctx: unknown): {
clientId?: string;
userId?: string;
ipAddress?: string;
userAgent?: string;
redirectUri?: string;
responseType?: string;
scope?: string;
grantType?: string;
interactionId?: string;
} {
const oidcCtx = ctx as {
ip?: string;
headers?: Record<string, unknown>;
oidc?: {
client?: { clientId?: string };
session?: { accountId?: string };
params?: Record<string, unknown>;
entities?: { Interaction?: { uid?: string } };
};
};
const params = oidcCtx?.oidc?.params ?? {};
return {
clientId: oidcCtx?.oidc?.client?.clientId ?? this.stringProperty(params.client_id),
userId: oidcCtx?.oidc?.session?.accountId,
ipAddress: oidcCtx?.ip,
userAgent: this.stringProperty(oidcCtx?.headers?.['user-agent']),
redirectUri: this.stringProperty(params.redirect_uri),
responseType: this.stringProperty(params.response_type),
scope: this.stringProperty(params.scope),
grantType: this.stringProperty(params.grant_type),
interactionId: oidcCtx?.oidc?.entities?.Interaction?.uid,
};
}
private registerErrorEvents(provider: Provider): void {
@@ -308,6 +406,7 @@ export class OidcProviderService implements OnModuleInit {
source.on(eventName, (ctx, error) => {
const actualError = error ?? ctx;
this.consoleLogOidcProviderError(eventName, ctx, actualError);
this.auditOidcFailure(eventName, ctx, actualError);
void this.logOidcProviderError(eventName, ctx, actualError).catch((logError) => {
this.logger.error(`OIDC provider error logging failed: ${this.errorProperty(logError, 'message')}`);
});
@@ -393,4 +492,27 @@ export class OidcProviderService implements OnModuleInit {
private errorProperty(error: unknown, property: 'name' | 'message'): string | undefined {
return error instanceof Error ? error[property] : undefined;
}
private auditOidcFailure(eventName: string, ctx: unknown, error: unknown): void {
if (eventName !== 'authorization.error' && eventName !== 'grant.error') {
return;
}
const activity = this.oidcActivityContext(ctx);
void this.audit.record({
type: eventName === 'authorization.error' ? 'oidc.client_login_failed' : 'oidc.client_token_failed',
username: activity.userId,
ipAddress: activity.ipAddress,
userAgent: activity.userAgent,
metadata: {
clientId: activity.clientId,
errorName: this.errorProperty(error, 'name'),
errorMessage: this.errorProperty(error, 'message'),
},
});
}
private stringProperty(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}
}

View File

@@ -1,7 +1,7 @@
import { HttpClient } from '@angular/common/http';
import { Inject, Injectable, signal } from '@angular/core';
import { Router } from '@angular/router';
import { tap } from 'rxjs';
import { catchError, EMPTY, tap } from 'rxjs';
import { API_BASE_URL } from './api-base-url';
interface LoginResponse {
@@ -34,6 +34,7 @@ export class AuthService {
}
logout(): void {
this.http.post<void>(`${this.apiBaseUrl}/auth/logout`, {}).pipe(catchError(() => EMPTY)).subscribe();
localStorage.removeItem('accessToken');
localStorage.removeItem('username');
this.username.set(null);