Files
ldap-identidy/apps/api/src/oidc/oidc-provider.service.ts
Bastian Wagner 49bf8d6168 logs
2026-07-17 12:39:48 +02:00

455 lines
16 KiB
TypeScript

import { Injectable, InternalServerErrorException, Logger, OnModuleInit, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpAdapterHost } from '@nestjs/core';
import { InjectRepository } from '@nestjs/typeorm';
import { Request, Response } from 'express';
import type Provider from 'oidc-provider';
import type { AccountClaims, Adapter, Configuration, Interaction } from 'oidc-provider';
import { Repository } from 'typeorm';
import { ApplicationErrorCategory, ApplicationErrorCode } from '../application-error-log/application-error-codes';
import { ApplicationErrorLoggerService } from '../application-error-log/application-error-logger.service';
import { AuditService } from '../audit/audit.service';
import { RequestContextService } from '../common/request-context.service';
import { LdapAuthService } from '../lldap/ldap-auth.service';
import { LldapService } from '../lldap/lldap.service';
import { OidcProviderStorageEntity } from './entities/oidc-provider-storage.entity';
import { OidcSigningKeyEntity } from './entities/oidc-signing-key.entity';
import { OidcSubjectEntity } from './entities/oidc-subject.entity';
import { OidcClientService } from './oidc-client.service';
import { TypeormOidcAdapter } from './typeorm-oidc.adapter';
type OidcModuleImport = typeof import('oidc-provider');
type JoseImport = typeof import('jose');
@Injectable()
export class OidcProviderService implements OnModuleInit {
private readonly logger = new Logger(OidcProviderService.name);
private provider?: Provider;
constructor(
@InjectRepository(OidcProviderStorageEntity)
private readonly storage: Repository<OidcProviderStorageEntity>,
@InjectRepository(OidcSigningKeyEntity)
private readonly signingKeys: Repository<OidcSigningKeyEntity>,
@InjectRepository(OidcSubjectEntity)
private readonly subjects: Repository<OidcSubjectEntity>,
private readonly clients: OidcClientService,
private readonly config: ConfigService,
private readonly httpAdapterHost: HttpAdapterHost,
private readonly ldapAuth: LdapAuthService,
private readonly lldap: LldapService,
private readonly audit: AuditService,
private readonly applicationErrorLogger: ApplicationErrorLoggerService,
private readonly requestContext: RequestContextService,
) {}
async onModuleInit(): Promise<void> {
const oidc = await this.importOidcProvider();
const jwks = await this.loadOrCreateJwks();
const issuer = this.config.get<string>('OIDC_ISSUER') ?? `http://localhost:${this.config.get('API_PORT') ?? 3000}`;
this.provider = new oidc.default(issuer, this.buildConfiguration(jwks));
this.provider.proxy = this.config.get('OIDC_TRUST_PROXY') === 'true';
this.registerAuditEvents(this.provider);
this.registerErrorEvents(this.provider);
const expressApp = this.httpAdapterHost.httpAdapter.getInstance();
expressApp.use(this.provider.callback());
}
async interactionDetails(request: Request, response: Response): Promise<Interaction> {
return this.getProvider().interactionDetails(request, response);
}
async finishLogin(
request: Request,
response: Response,
uid: string,
username: string,
password: string,
): Promise<void> {
const details = await this.interactionDetails(request, response);
if (details.uid !== uid || details.prompt.name !== 'login') {
throw new UnauthorizedException('Ungueltige OIDC-Interaktion.');
}
const valid = await this.ldapAuth.verifyPassword(username, password);
if (!valid) {
await this.audit.record({ type: 'oidc.login_failed', username, ipAddress: request.ip, userAgent: request.headers['user-agent'] });
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.getProvider().interactionFinished(
request,
response,
{
login: {
accountId: subject,
acr: 'urn:ldap-portal:password',
amr: ['pwd'],
remember: true,
ts: Math.floor(Date.now() / 1000),
},
},
{ mergeWithLastSubmission: false },
);
}
async finishConsent(
request: Request,
response: Response,
uid: string,
options: { autoGranted?: boolean } = {},
): Promise<void> {
const details = await this.interactionDetails(request, response);
if (details.uid !== uid || details.prompt.name !== 'consent') {
throw new UnauthorizedException('Ungueltige OIDC-Interaktion.');
}
const clientId = String(details.params.client_id ?? '');
const accountId = details.session?.accountId;
if (!clientId || !accountId) {
throw new InternalServerErrorException('OIDC consent context is incomplete');
}
const Grant = (this.getProvider() as unknown as { Grant: any }).Grant;
const grant = details.grantId
? await Grant.find(details.grantId)
: new Grant({ accountId, clientId });
const scope = String(details.params.scope ?? 'openid');
grant.addOIDCScope(scope);
if (details.prompt.details?.missingOIDCClaims) {
grant.addOIDCClaims(details.prompt.details.missingOIDCClaims);
}
const grantId = await grant.save();
await this.audit.record({
type: options.autoGranted ? 'oidc.consent_auto_granted' : 'oidc.consent_granted',
username: accountId,
metadata: { clientId, scope },
});
await this.getProvider().interactionFinished(
request,
response,
{ consent: { grantId } },
{ mergeWithLastSubmission: true },
);
}
async abortInteraction(request: Request, response: Response): Promise<void> {
await this.getProvider().interactionFinished(
request,
response,
{
error: 'access_denied',
error_description: 'End-User aborted interaction',
},
{ mergeWithLastSubmission: false },
);
}
async isFirstPartyClient(clientId: string): Promise<boolean> {
const client = await this.clients.findByClientId(clientId);
return client?.firstParty === true;
}
private buildConfiguration(jwks: { keys: Record<string, unknown>[] }): Configuration {
return {
adapter: (name: string): Adapter => new TypeormOidcAdapter(name, this.storage, this.clients),
jwks,
clientDefaults: {
grant_types: ['authorization_code'],
response_types: ['code'],
id_token_signed_response_alg: 'ES256',
token_endpoint_auth_method: 'client_secret_basic',
},
claims: {
openid: ['sub'],
profile: ['name', 'preferred_username', 'given_name', 'family_name', 'updated_at'],
email: ['email', 'email_verified'],
groups: ['groups'],
},
scopes: ['openid', 'profile', 'email', 'groups', 'offline_access'],
routes: {
authorization: '/oidc/auth',
token: '/oidc/token',
userinfo: '/oidc/me',
jwks: '/oidc/jwks',
end_session: '/oidc/session/end',
revocation: '/oidc/token/revocation',
introspection: '/oidc/token/introspection',
},
interactions: {
url: (_ctx, interaction) => `/interaction/${interaction.uid}`,
},
features: {
devInteractions: { enabled: false },
revocation: { enabled: true },
introspection: { enabled: true },
rpInitiatedLogout: { enabled: true },
},
cookies: {
keys: [this.config.get<string>('OIDC_COOKIE_SECRET') ?? this.config.getOrThrow<string>('TOKEN_SECRET')],
short: {
httpOnly: true,
sameSite: 'lax',
secure: this.config.get('NODE_ENV') === 'production',
},
long: {
httpOnly: true,
sameSite: 'lax',
secure: this.config.get('NODE_ENV') === 'production',
},
},
pkce: {
required: () => true,
},
ttl: {
AccessToken: 10 * 60,
AuthorizationCode: 10 * 60,
IdToken: 10 * 60,
RefreshToken: 14 * 24 * 60 * 60,
Session: 8 * 60 * 60,
},
findAccount: async (_ctx, sub) => {
const subject = await this.subjects.findOneBy({ subject: sub });
if (!subject) {
return undefined;
}
return {
accountId: sub,
claims: async () => this.claimsFor(subject.username, sub),
};
},
};
}
private async claimsFor(username: string, sub: string): Promise<AccountClaims> {
const account = await this.lldap.getAccount(username);
const claims: AccountClaims = {
sub,
preferred_username: account.id,
name: account.displayName || account.id,
email: account.email,
email_verified: Boolean(account.email),
given_name: account.firstName,
family_name: account.lastName,
updated_at: Math.floor(new Date(account.creationDate).getTime() / 1000),
};
const client = await this.currentClient();
if (!client || client.includeGroups) {
claims.groups = account.groups.map((group) => group.displayName);
}
return claims;
}
private async currentClient() {
const oidcModule = await this.importOidcProvider();
const ctx = oidcModule.Provider.ctx;
const clientId = ctx?.oidc?.client?.clientId;
return clientId ? this.clients.findByClientId(clientId) : null;
}
private async loadOrCreateJwks(): Promise<{ keys: Record<string, unknown>[] }> {
const active = await this.signingKeys.find({ where: { active: true }, order: { createdAt: 'DESC' } });
if (active.length) {
return { keys: active.map((key) => key.jwk) };
}
const jose = await this.importJose();
const { privateKey } = await jose.generateKeyPair('ES256', { extractable: true });
const jwk = (await jose.exportJWK(privateKey)) as Record<string, unknown>;
jwk.kid = `sig-${Date.now()}`;
jwk.alg = 'ES256';
jwk.use = 'sig';
await this.signingKeys.save(this.signingKeys.create({ kid: String(jwk.kid), active: true, jwk }));
return { keys: [jwk] };
}
private registerAuditEvents(provider: Provider): void {
provider.on('authorization_code.saved', (code) => {
void this.audit.record({ type: 'oidc.authorization_code_saved', username: code.accountId, metadata: { clientId: code.clientId } });
});
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', (ctx, prompt) => {
const oidcContext = ctx as {
path?: string;
oidc?: {
client?: { clientId?: string };
params?: Record<string, unknown>;
entities?: { Interaction?: { uid?: string } };
};
};
void this.audit.record({
type: 'oidc.interaction_started',
metadata: {
prompt: typeof prompt === 'object' && prompt && 'name' in prompt ? String(prompt.name) : undefined,
path: oidcContext.path,
clientId: oidcContext.oidc?.client?.clientId ?? oidcContext.oidc?.params?.client_id,
redirectUri: oidcContext.oidc?.params?.redirect_uri,
scope: oidcContext.oidc?.params?.scope,
interactionUid: oidcContext.oidc?.entities?.Interaction?.uid,
},
});
});
}
private registerErrorEvents(provider: Provider): void {
const eventSource = provider as unknown as {
on(eventName: string, listener: (ctx: unknown, error: unknown) => void): void;
};
const errorEvents = [
'authorization.error',
'server_error',
'grant.error',
'userinfo.error',
'jwks.error',
'discovery.error',
'end_session.error',
'revocation.error',
'introspection.error',
];
for (const eventName of errorEvents) {
eventSource.on(eventName, (ctx, error) => {
this.consoleLogOidcProviderError(eventName, ctx, error);
this.logOidcProviderError(eventName, ctx, error).catch((logError) => {
const message = logError instanceof Error ? logError.message : String(logError);
this.logger.error(`OIDC provider error logging failed for ${eventName}: ${message}`);
});
});
}
}
private async logOidcProviderError(eventName: string, ctx: unknown, error: unknown): Promise<void> {
const oidcContext = ctx as {
method?: string;
path?: string;
status?: number;
query?: Record<string, unknown>;
req?: { headers?: Record<string, string | string[] | undefined> };
oidc?: {
route?: string;
client?: { clientId?: string };
params?: Record<string, unknown>;
body?: Record<string, unknown>;
};
};
const params = oidcContext.oidc?.params ?? oidcContext.query ?? {};
const currentRequestContext = this.requestContext.get();
const correlationHeader = oidcContext.req?.headers?.['x-correlation-id'];
const correlationId =
currentRequestContext.correlationId ??
(Array.isArray(correlationHeader) ? correlationHeader[0] : correlationHeader);
await this.applicationErrorLogger.log({
error,
category: ApplicationErrorCategory.OIDC,
code:
eventName === 'server_error'
? ApplicationErrorCode.OIDC_PROVIDER_ERROR
: ApplicationErrorCode.OIDC_AUTHORIZATION_ERROR,
module: 'OidcModule',
service: OidcProviderService.name,
operation: eventName,
requestContext: {
correlationId,
method: oidcContext.method,
path: oidcContext.path,
statusCode: oidcContext.status,
},
context: {
eventName,
route: oidcContext.oidc?.route,
clientId: oidcContext.oidc?.client?.clientId ?? params.client_id,
redirectUri: params.redirect_uri,
responseType: params.response_type,
responseMode: params.response_mode,
scope: params.scope,
prompt: params.prompt,
error: this.errorProperty(error, 'error'),
errorDescription: this.errorProperty(error, 'error_description'),
errorDetail: this.errorProperty(error, 'error_detail'),
},
handled: true,
});
}
private consoleLogOidcProviderError(eventName: string, ctx: unknown, error: unknown): void {
const oidcContext = ctx as {
method?: string;
path?: string;
status?: number;
query?: Record<string, unknown>;
req?: { headers?: Record<string, string | string[] | undefined> };
oidc?: {
route?: string;
client?: { clientId?: string };
params?: Record<string, unknown>;
};
};
const params = oidcContext.oidc?.params ?? oidcContext.query ?? {};
const errorName = error instanceof Error ? error.name : typeof error;
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
console.error(
'[OIDC_PROVIDER_ERROR]',
JSON.stringify({
eventName,
errorName,
errorMessage,
method: oidcContext.method,
path: oidcContext.path,
status: oidcContext.status,
route: oidcContext.oidc?.route,
clientId: oidcContext.oidc?.client?.clientId ?? params.client_id,
redirectUri: params.redirect_uri,
responseType: params.response_type,
scope: params.scope,
host: oidcContext.req?.headers?.host,
forwardedProto: oidcContext.req?.headers?.['x-forwarded-proto'],
forwardedHost: oidcContext.req?.headers?.['x-forwarded-host'],
}),
errorStack ?? '',
);
}
private errorProperty(error: unknown, property: string): unknown {
if (!error || typeof error !== 'object' || !(property in error)) {
return undefined;
}
return (error as Record<string, unknown>)[property];
}
private getProvider(): Provider {
if (!this.provider) {
throw new InternalServerErrorException('OIDC provider is not initialized');
}
return this.provider;
}
private async importOidcProvider(): Promise<OidcModuleImport> {
return new Function('specifier', 'return import(specifier)')('oidc-provider') as Promise<OidcModuleImport>;
}
private async importJose(): Promise<JoseImport> {
return new Function('specifier', 'return import(specifier)')('jose') as Promise<JoseImport>;
}
}