470 lines
13 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|
|
}
|