initial
This commit is contained in:
280
apps/api/src/oidc/oidc-provider.service.ts
Normal file
280
apps/api/src/oidc/oidc-provider.service.ts
Normal 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>;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user