455 lines
16 KiB
TypeScript
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>;
|
|
}
|
|
}
|