Files
listify/listify-api/src/auth/oidc.service.ts
Bastian Wagner f45583f3ea SSO implementiert
2026-07-14 17:32:48 +02:00

234 lines
6.4 KiB
TypeScript

import {
BadRequestException,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import { createHash, randomBytes } from 'crypto';
type JoseModule = typeof import('jose');
type RemoteJwkSet = ReturnType<JoseModule['createRemoteJWKSet']>;
export interface OidcProfile {
subject: string;
email: string;
name?: string;
}
interface OidcDiscovery {
authorization_endpoint: string;
token_endpoint: string;
jwks_uri: string;
issuer: string;
}
interface PendingOidcState {
codeVerifier: string;
nonce: string;
expiresAt: number;
}
interface TokenResponse {
id_token?: string;
error?: string;
error_description?: string;
}
@Injectable()
export class OidcService {
private readonly pendingStates = new Map<string, PendingOidcState>();
private discovery?: OidcDiscovery;
private jose?: Promise<JoseModule>;
private jwks?: RemoteJwkSet;
async createAuthorizationUrl(): Promise<string> {
const config = this.getConfig();
const discovery = await this.getDiscovery(config.discoveryUrl);
const state = this.createOpaqueToken();
const nonce = this.createOpaqueToken();
const codeVerifier = this.createOpaqueToken();
const codeChallenge = this.codeChallenge(codeVerifier);
const authorizationUrl = new URL(discovery.authorization_endpoint);
this.pendingStates.set(state, {
codeVerifier,
nonce,
expiresAt: Date.now() + 10 * 60 * 1000,
});
this.deleteExpiredStates();
authorizationUrl.searchParams.set('response_type', 'code');
authorizationUrl.searchParams.set('client_id', config.clientId);
authorizationUrl.searchParams.set('redirect_uri', config.callbackUrl);
authorizationUrl.searchParams.set('scope', 'openid email profile');
authorizationUrl.searchParams.set('state', state);
authorizationUrl.searchParams.set('nonce', nonce);
authorizationUrl.searchParams.set('code_challenge', codeChallenge);
authorizationUrl.searchParams.set('code_challenge_method', 'S256');
return authorizationUrl.toString();
}
async exchangeCallback(code?: string, state?: string): Promise<OidcProfile> {
if (!code || !state) {
throw new BadRequestException('OIDC code and state are required.');
}
const pendingState = this.pendingStates.get(state);
this.pendingStates.delete(state);
if (!pendingState || pendingState.expiresAt <= Date.now()) {
throw new BadRequestException('OIDC state is invalid or expired.');
}
const config = this.getConfig();
const discovery = await this.getDiscovery(config.discoveryUrl);
const tokenResponse = await this.requestTokens(
discovery,
config,
code,
pendingState.codeVerifier,
);
if (!tokenResponse.id_token) {
throw new ServiceUnavailableException(
tokenResponse.error_description ??
tokenResponse.error ??
'OIDC token response did not include an ID token.',
);
}
const [{ jwtVerify }, jwks] = await Promise.all([
this.getJose(),
this.getJwks(discovery.jwks_uri),
]);
const { payload } = await jwtVerify(tokenResponse.id_token, jwks, {
issuer: discovery.issuer,
audience: config.clientId,
});
if (payload.nonce !== pendingState.nonce) {
throw new BadRequestException('OIDC nonce is invalid.');
}
if (!payload.sub || typeof payload.sub !== 'string') {
throw new BadRequestException('OIDC subject is missing.');
}
const email = typeof payload.email === 'string' ? payload.email : undefined;
if (!email) {
throw new BadRequestException('OIDC email claim is missing.');
}
return {
subject: payload.sub,
email,
name: typeof payload.name === 'string' ? payload.name : undefined,
};
}
private async requestTokens(
discovery: OidcDiscovery,
config: ReturnType<OidcService['getConfig']>,
code: string,
codeVerifier: string,
): Promise<TokenResponse> {
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: config.callbackUrl,
client_id: config.clientId,
code_verifier: codeVerifier,
});
if (config.clientSecret) {
body.set('client_secret', config.clientSecret);
}
const response = await fetch(discovery.token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
const payload = (await response.json().catch(() => ({}))) as TokenResponse;
if (!response.ok) {
throw new ServiceUnavailableException(
payload.error_description ??
payload.error ??
'OIDC token request failed.',
);
}
return payload;
}
private async getDiscovery(discoveryUrl: string): Promise<OidcDiscovery> {
if (this.discovery) {
return this.discovery;
}
const response = await fetch(discoveryUrl);
if (!response.ok) {
throw new ServiceUnavailableException('OIDC discovery failed.');
}
this.discovery = (await response.json()) as OidcDiscovery;
return this.discovery;
}
private async getJwks(jwksUri: string): Promise<RemoteJwkSet> {
const { createRemoteJWKSet } = await this.getJose();
this.jwks ??= createRemoteJWKSet(new URL(jwksUri));
return this.jwks;
}
private getJose(): Promise<JoseModule> {
this.jose ??= import('jose');
return this.jose;
}
private getConfig() {
const issuerUrl = process.env.OIDC_ISSUER_URL;
const explicitDiscoveryUrl = process.env.OIDC_DISCOVERY_URL;
const clientId = process.env.OIDC_CLIENT_ID;
const callbackUrl = process.env.OIDC_CALLBACK_URL;
if (!issuerUrl || !clientId || !callbackUrl) {
throw new ServiceUnavailableException(
'OIDC configuration is incomplete.',
);
}
return {
issuerUrl,
discoveryUrl:
explicitDiscoveryUrl ??
`${issuerUrl.replace(/\/$/, '')}/.well-known/openid-configuration`,
clientId,
callbackUrl,
clientSecret: process.env.OIDC_CLIENT_SECRET,
};
}
private createOpaqueToken(): string {
return randomBytes(32).toString('base64url');
}
private codeChallenge(codeVerifier: string): string {
return createHash('sha256').update(codeVerifier).digest('base64url');
}
private deleteExpiredStates(): void {
const now = Date.now();
for (const [state, pendingState] of this.pendingStates.entries()) {
if (pendingState.expiresAt <= now) {
this.pendingStates.delete(state);
}
}
}
}