94 lines
2.7 KiB
TypeScript
94 lines
2.7 KiB
TypeScript
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|
import {
|
|
createRemoteJWKSet,
|
|
jwtVerify,
|
|
} from 'jose';
|
|
|
|
export interface AuthenticatedUser {
|
|
sub: string;
|
|
username?: string;
|
|
email?: string;
|
|
name?: string;
|
|
roles: string[];
|
|
}
|
|
|
|
type RemoteJwks = ReturnType<typeof createRemoteJWKSet>;
|
|
|
|
@Injectable()
|
|
export class AuthService {
|
|
private jwksPromise: Promise<RemoteJwks> | null = null;
|
|
|
|
async verifyAccessToken(token: string): Promise<AuthenticatedUser> {
|
|
const issuer = process.env.KEYCLOAK_ISSUER_URL;
|
|
const audience = process.env.KEYCLOAK_AUDIENCE;
|
|
|
|
if (!issuer || !audience) {
|
|
throw new UnauthorizedException('Keycloak issuer or audience is not configured');
|
|
}
|
|
|
|
try {
|
|
const jwks = await this.getJwks();
|
|
const result = await jwtVerify(token, jwks, {
|
|
issuer,
|
|
audience,
|
|
});
|
|
|
|
return this.mapPayloadToUser(result);
|
|
} catch (error) {
|
|
throw new UnauthorizedException('Token validation failed');
|
|
}
|
|
}
|
|
|
|
private async getJwks(): Promise<RemoteJwks> {
|
|
if (!this.jwksPromise) {
|
|
this.jwksPromise = this.resolveJwks().catch((error) => {
|
|
this.jwksPromise = null;
|
|
throw error;
|
|
});
|
|
}
|
|
|
|
return this.jwksPromise;
|
|
}
|
|
|
|
private async resolveJwks(): Promise<RemoteJwks> {
|
|
const issuer = process.env.KEYCLOAK_ISSUER_URL;
|
|
if (!issuer) {
|
|
throw new UnauthorizedException('KEYCLOAK_ISSUER_URL is not configured');
|
|
}
|
|
|
|
const explicitJwksUrl = process.env.KEYCLOAK_JWKS_URL;
|
|
if (explicitJwksUrl) {
|
|
return createRemoteJWKSet(new URL(explicitJwksUrl));
|
|
}
|
|
|
|
try {
|
|
const discoveryUrl = new URL('.well-known/openid-configuration', `${issuer.replace(/\/$/, '')}/`);
|
|
const response = await fetch(discoveryUrl);
|
|
if (response.ok) {
|
|
const discovery = (await response.json()) as { jwks_uri?: string };
|
|
if (discovery.jwks_uri) {
|
|
return createRemoteJWKSet(new URL(discovery.jwks_uri));
|
|
}
|
|
}
|
|
} catch {
|
|
// Fall through to the Keycloak certs endpoint.
|
|
}
|
|
|
|
return createRemoteJWKSet(new URL(`${issuer.replace(/\/$/, '')}/protocol/openid-connect/certs`));
|
|
}
|
|
|
|
private mapPayloadToUser(result: Awaited<ReturnType<typeof jwtVerify>>): AuthenticatedUser {
|
|
const payload = result.payload;
|
|
const realmAccess = payload.realm_access as { roles?: string[] } | undefined;
|
|
const roles = Array.isArray(realmAccess?.roles) ? realmAccess.roles : [];
|
|
|
|
return {
|
|
sub: String(payload.sub ?? ''),
|
|
username: typeof payload.preferred_username === 'string' ? payload.preferred_username : undefined,
|
|
email: typeof payload.email === 'string' ? payload.email : undefined,
|
|
name: typeof payload.name === 'string' ? payload.name : undefined,
|
|
roles,
|
|
};
|
|
}
|
|
}
|