idp angepasst
This commit is contained in:
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user