This commit is contained in:
Bastian Wagner
2026-07-15 14:09:28 +02:00
commit 3e5348b7ec
104 changed files with 30367 additions and 0 deletions

View File

@@ -0,0 +1,280 @@
import { Injectable, InternalServerErrorException, 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 { AuditService } from '../audit/audit.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 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,
) {}
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);
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): 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 });
grant.addOIDCScope(String(details.params.scope ?? 'openid'));
if (details.prompt.details?.missingOIDCClaims) {
grant.addOIDCClaims(details.prompt.details.missingOIDCClaims);
}
const grantId = await grant.save();
await this.audit.record({ type: 'oidc.consent_granted', username: accountId, metadata: { clientId } });
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 },
);
}
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'],
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 } });
});
}
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>;
}
}