idp angepasst

This commit is contained in:
Bastian Wagner
2026-07-15 16:59:17 +02:00
parent 0dfba43aa6
commit fadb5dd049
59 changed files with 1954 additions and 367 deletions

View File

@@ -4,6 +4,7 @@ import {
ServiceUnavailableException,
} from '@nestjs/common';
import { createHash, randomBytes } from 'crypto';
import type { JWTPayload } from 'jose';
type JoseModule = typeof import('jose');
type RemoteJwkSet = ReturnType<JoseModule['createRemoteJWKSet']>;
@@ -12,15 +13,21 @@ 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 {
@@ -36,6 +43,15 @@ interface TokenResponse {
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>();
@@ -45,7 +61,7 @@ export class OidcService {
async createAuthorizationUrl(): Promise<string> {
const config = this.getConfig();
const discovery = await this.getDiscovery(config.discoveryUrl);
const discovery = await this.getDiscovery(config);
const state = this.createOpaqueToken();
const nonce = this.createOpaqueToken();
const codeVerifier = this.createOpaqueToken();
@@ -61,8 +77,8 @@ export class OidcService {
authorizationUrl.searchParams.set('response_type', 'code');
authorizationUrl.searchParams.set('client_id', config.clientId);
authorizationUrl.searchParams.set('redirect_uri', config.callbackUrl);
authorizationUrl.searchParams.set('scope', config.scope);
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);
@@ -84,7 +100,7 @@ export class OidcService {
}
const config = this.getConfig();
const discovery = await this.getDiscovery(config.discoveryUrl);
const discovery = await this.getDiscovery(config);
const tokenResponse = await this.requestTokens(
discovery,
config,
@@ -92,12 +108,11 @@ export class OidcService {
pendingState.codeVerifier,
);
console.log(tokenResponse)
if (!tokenResponse.id_token) {
if (!tokenResponse.id_token || !tokenResponse.access_token) {
throw new ServiceUnavailableException(
tokenResponse.error_description ??
tokenResponse.error ??
'OIDC token response did not include an ID token.',
'OIDC token response did not include the required tokens.',
);
}
@@ -105,41 +120,85 @@ export class OidcService {
this.getJose(),
this.getJwks(discovery.jwks_uri),
]);
const { payload } = await jwtVerify(tokenResponse.id_token, jwks, {
issuer: discovery.issuer,
audience: config.clientId,
});
const { payload: idTokenPayload } = await jwtVerify(
tokenResponse.id_token,
jwks,
{
issuer: discovery.issuer,
audience: config.clientId,
},
);
if (payload.nonce !== pendingState.nonce) {
if (idTokenPayload.nonce !== pendingState.nonce) {
throw new BadRequestException('OIDC nonce is invalid.');
}
if (!payload.sub || typeof payload.sub !== 'string') {
if (!idTokenPayload.sub || typeof idTokenPayload.sub !== 'string') {
throw new BadRequestException('OIDC subject is missing.');
}
const email = typeof payload.email === 'string' ? payload.email : undefined;
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 idTokenGroups = this.extractGroups(payload, config.groupsClaim);
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: payload.sub,
subject: idTokenPayload.sub,
email,
name: typeof payload.name === 'string' ? payload.name : undefined,
groups: idTokenGroups.length
? idTokenGroups
: await this.requestUserInfoGroups(
discovery.userinfo_endpoint,
tokenResponse.access_token,
config.groupsClaim,
),
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 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,
);
}
return logoutUrl.toString();
}
private async requestTokens(
discovery: OidcDiscovery,
config: ReturnType<OidcService['getConfig']>,
@@ -149,14 +208,12 @@ export class OidcService {
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: config.callbackUrl,
redirect_uri: config.redirectUri,
client_id: config.clientId,
code_verifier: codeVerifier,
});
if (config.clientSecret) {
body.set('client_secret', config.clientSecret);
}
this.addClientAuthentication(body, config);
const response = await fetch(discovery.token_endpoint, {
method: 'POST',
@@ -176,18 +233,81 @@ export class OidcService {
return payload;
}
private async getDiscovery(discoveryUrl: string): Promise<OidcDiscovery> {
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(discoveryUrl);
const response = await fetch(config.discoveryUrl);
if (!response.ok) {
throw new ServiceUnavailableException('OIDC discovery failed.');
}
this.discovery = (await response.json()) as OidcDiscovery;
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;
}
@@ -204,37 +324,38 @@ export class OidcService {
}
private getConfig() {
const issuerUrl = process.env.OIDC_ISSUER_URL;
const explicitDiscoveryUrl = process.env.OIDC_DISCOVERY_URL;
const issuer = this.normalizeIssuer(
process.env.OIDC_ISSUER ?? process.env.OIDC_ISSUER_URL,
);
const clientId = process.env.OIDC_CLIENT_ID;
const callbackUrl = process.env.OIDC_CALLBACK_URL;
const redirectUri =
process.env.OIDC_REDIRECT_URI ?? process.env.OIDC_CALLBACK_URL;
if (!issuerUrl || !clientId || !callbackUrl) {
if (!issuer || !clientId || !redirectUri) {
throw new ServiceUnavailableException(
'OIDC configuration is incomplete.',
'OIDC configuration is incomplete. Required: OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_REDIRECT_URI.',
);
}
return {
issuerUrl,
discoveryUrl:
explicitDiscoveryUrl ??
`${issuerUrl.replace(/\/$/, '')}/.well-known/openid-configuration`,
issuer,
discoveryUrl: `${issuer}/.well-known/openid-configuration`,
clientId,
callbackUrl,
redirectUri,
clientSecret: process.env.OIDC_CLIENT_SECRET,
scope: process.env.OIDC_SCOPE ?? 'openid email profile',
scopes: process.env.OIDC_SCOPES ?? 'openid profile email groups',
postLogoutRedirectUri: process.env.OIDC_POST_LOGOUT_REDIRECT_URI,
accessTokenAudience: process.env.OIDC_ACCESS_TOKEN_AUDIENCE ?? clientId,
groupsClaim: process.env.OIDC_GROUPS_CLAIM ?? 'groups',
};
}
private async requestUserInfoGroups(
private async requestUserInfo(
userInfoEndpoint: string | undefined,
accessToken: string | undefined,
groupsClaim: string,
): Promise<string[]> {
): Promise<Record<string, unknown>> {
if (!userInfoEndpoint || !accessToken) {
return [];
return {};
}
const response = await fetch(userInfoEndpoint, {
@@ -242,15 +363,41 @@ export class OidcService {
});
if (!response.ok) {
return [];
return {};
}
const payload = (await response.json().catch(() => ({}))) as Record<
string,
unknown
>;
return (await response.json().catch(() => ({}))) as Record<string, unknown>;
}
return this.extractGroups(payload, groupsClaim);
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(
@@ -270,6 +417,31 @@ export class OidcService {
.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');
}