Files
listify/listify-api/src/auth/oidc.service.ts
Bastian Wagner 4a6e0f3b0f logout
2026-07-17 15:32:01 +02:00

470 lines
13 KiB
TypeScript

import {
BadRequestException,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import { createHash, randomBytes } from 'crypto';
import type { JWTPayload } from 'jose';
type JoseModule = typeof import('jose');
type RemoteJwkSet = ReturnType<JoseModule['createRemoteJWKSet']>;
export interface OidcProfile {
subject: string;
email: string;
name?: string;
preferredUsername?: string;
givenName?: string;
familyName?: string;
groups: string[];
idToken: string;
}
interface OidcDiscovery {
authorization_endpoint: string;
token_endpoint: string;
introspection_endpoint?: string;
userinfo_endpoint?: string;
jwks_uri: string;
issuer: string;
end_session_endpoint?: string;
}
interface PendingOidcState {
codeVerifier: string;
nonce: string;
expiresAt: number;
}
interface TokenResponse {
id_token?: string;
access_token?: string;
error?: string;
error_description?: string;
}
interface TokenIntrospectionResponse {
active?: boolean;
sub?: string;
iss?: string;
aud?: string | 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);
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.redirectUri);
authorizationUrl.searchParams.set('scope', config.scopes);
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);
const tokenResponse = await this.requestTokens(
discovery,
config,
code,
pendingState.codeVerifier,
);
if (!tokenResponse.id_token || !tokenResponse.access_token) {
throw new ServiceUnavailableException(
tokenResponse.error_description ??
tokenResponse.error ??
'OIDC token response did not include the required tokens.',
);
}
const [{ jwtVerify }, jwks] = await Promise.all([
this.getJose(),
this.getJwks(discovery.jwks_uri),
]);
const { payload: idTokenPayload } = await jwtVerify(
tokenResponse.id_token,
jwks,
{
issuer: discovery.issuer,
audience: config.clientId,
},
);
if (idTokenPayload.nonce !== pendingState.nonce) {
throw new BadRequestException('OIDC nonce is invalid.');
}
if (!idTokenPayload.sub || typeof idTokenPayload.sub !== 'string') {
throw new BadRequestException('OIDC subject is missing.');
}
await this.introspectAccessToken(
discovery,
config,
tokenResponse.access_token,
idTokenPayload.sub,
);
const userInfo = await this.requestUserInfo(
discovery.userinfo_endpoint,
tokenResponse.access_token,
);
this.validateUserInfoSubject(userInfo, idTokenPayload.sub);
const mergedClaims = { ...idTokenPayload, ...userInfo };
const email = this.stringClaim(mergedClaims, 'email');
if (!email) {
throw new BadRequestException('OIDC email claim is missing.');
}
const groups = this.extractGroups(mergedClaims, config.groupsClaim);
const givenName = this.stringClaim(mergedClaims, 'given_name');
const familyName = this.stringClaim(mergedClaims, 'family_name');
const preferredUsername = this.stringClaim(
mergedClaims,
'preferred_username',
);
return {
subject: idTokenPayload.sub,
email,
name: this.displayName(mergedClaims, preferredUsername),
preferredUsername,
givenName,
familyName,
groups,
idToken: tokenResponse.id_token,
};
}
async createLogoutUrl(idTokenHint?: string): Promise<string> {
const config = this.getConfig();
const discovery = await this.getDiscovery(config);
const state = this.createOpaqueToken();
const logoutUrl = new URL(
discovery.end_session_endpoint ??
`${config.issuer.replace(/\/$/, '')}/oidc/session/end`,
);
if (idTokenHint) {
logoutUrl.searchParams.set('id_token_hint', idTokenHint);
}
if (config.postLogoutRedirectUri) {
logoutUrl.searchParams.set(
'post_logout_redirect_uri',
config.postLogoutRedirectUri,
);
}
logoutUrl.searchParams.set('state', state);
return logoutUrl.toString();
}
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.redirectUri,
client_id: config.clientId,
code_verifier: codeVerifier,
});
this.addClientAuthentication(body, config);
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 introspectAccessToken(
discovery: OidcDiscovery,
config: ReturnType<OidcService['getConfig']>,
accessToken: string,
expectedSubject: string,
): Promise<void> {
const body = new URLSearchParams({
token: accessToken,
token_type_hint: 'access_token',
});
this.addClientAuthentication(body, config);
const response = await fetch(
discovery.introspection_endpoint ??
`${config.issuer}/oidc/token/introspection`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
},
);
const payload = (await response
.json()
.catch(() => ({}))) as TokenIntrospectionResponse;
if (!response.ok) {
throw new ServiceUnavailableException(
payload.error_description ??
payload.error ??
'OIDC token introspection failed.',
);
}
if (payload.active !== true) {
throw new BadRequestException('OIDC access token is inactive.');
}
if (payload.iss && this.normalizeIssuer(payload.iss) !== config.issuer) {
throw new BadRequestException('OIDC access token issuer is invalid.');
}
if (payload.sub && payload.sub !== expectedSubject) {
throw new BadRequestException('OIDC access token subject is invalid.');
}
if (
payload.aud &&
!this.audienceIncludes(payload.aud, config.accessTokenAudience)
) {
throw new BadRequestException('OIDC access token audience is invalid.');
}
}
private async getDiscovery(
config: ReturnType<OidcService['getConfig']>,
): Promise<OidcDiscovery> {
if (this.discovery) {
return this.discovery;
}
const response = await fetch(config.discoveryUrl);
if (!response.ok) {
throw new ServiceUnavailableException('OIDC discovery failed.');
}
const discovery = (await response.json()) as OidcDiscovery;
if (this.normalizeIssuer(discovery.issuer) !== config.issuer) {
throw new ServiceUnavailableException(
'OIDC discovery issuer does not match OIDC_ISSUER.',
);
}
this.discovery = discovery;
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 issuer = this.normalizeIssuer(
process.env.OIDC_ISSUER ?? process.env.OIDC_ISSUER_URL,
);
const clientId = process.env.OIDC_CLIENT_ID;
const redirectUri =
process.env.OIDC_REDIRECT_URI ?? process.env.OIDC_CALLBACK_URL;
if (!issuer || !clientId || !redirectUri) {
throw new ServiceUnavailableException(
'OIDC configuration is incomplete. Required: OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_REDIRECT_URI.',
);
}
const postLogoutRedirectUri =
process.env.OIDC_POST_LOGOUT_REDIRECT_URI?.trim() ||
new URL('/auth/sso/logout-callback', redirectUri).toString();
return {
issuer,
discoveryUrl: `${issuer}/.well-known/openid-configuration`,
clientId,
redirectUri,
clientSecret: process.env.OIDC_CLIENT_SECRET,
scopes: process.env.OIDC_SCOPES ?? 'openid profile email groups',
postLogoutRedirectUri,
accessTokenAudience: process.env.OIDC_ACCESS_TOKEN_AUDIENCE ?? clientId,
groupsClaim: process.env.OIDC_GROUPS_CLAIM ?? 'groups',
};
}
private async requestUserInfo(
userInfoEndpoint: string | undefined,
accessToken: string | undefined,
): Promise<Record<string, unknown>> {
if (!userInfoEndpoint || !accessToken) {
return {};
}
const response = await fetch(userInfoEndpoint, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) {
return {};
}
return (await response.json().catch(() => ({}))) as Record<string, unknown>;
}
private addClientAuthentication(
body: URLSearchParams,
config: ReturnType<OidcService['getConfig']>,
): void {
body.set('client_id', config.clientId);
if (config.clientSecret) {
body.set('client_secret', config.clientSecret);
}
}
private validateUserInfoSubject(
userInfo: Record<string, unknown>,
expectedSubject: string,
): void {
const subject = userInfo.sub;
if (typeof subject === 'string' && subject !== expectedSubject) {
throw new BadRequestException('OIDC UserInfo subject is invalid.');
}
}
private audienceIncludes(
audience: string | string[],
expectedAudience: string,
): boolean {
return Array.isArray(audience)
? audience.includes(expectedAudience)
: audience === expectedAudience;
}
private extractGroups(
payload: Record<string, unknown>,
groupsClaim: string,
): string[] {
const claimValue = payload[groupsClaim];
if (!Array.isArray(claimValue)) {
return [];
}
return [...new Set(claimValue)]
.filter((group): group is string => typeof group === 'string')
.map((group) => group.trim())
.filter(Boolean)
.sort((left, right) => left.localeCompare(right));
}
private displayName(
payload: JWTPayload | Record<string, unknown>,
preferredUsername?: string,
): string | undefined {
const explicitName = this.stringClaim(payload, 'name');
const givenName = this.stringClaim(payload, 'given_name');
const familyName = this.stringClaim(payload, 'family_name');
const familyNameDisplay = [givenName, familyName].filter(Boolean).join(' ');
return explicitName ?? (familyNameDisplay || preferredUsername);
}
private stringClaim(
payload: JWTPayload | Record<string, unknown>,
claim: string,
): string | undefined {
const value = payload[claim];
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
private normalizeIssuer(issuer?: string): string {
return issuer?.trim().replace(/\/$/, '') ?? '';
}
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);
}
}
}
}