Files
clipperia-api/src/auth/keycloak.strategy.ts
2025-09-15 01:15:29 -03:00

122 lines
3.8 KiB
TypeScript

import { Injectable, UnauthorizedException, Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import * as jwksRsa from 'jwks-rsa';
export type JwtAudience = string | string[] | undefined;
export interface JwtRealmAccess {
roles: string[];
}
export interface JwtResourceAccessEntry {
roles: string[];
}
export type JwtResourceAccess =
| Record<string, JwtResourceAccessEntry>
| undefined;
export interface JwtPayload {
sub: string;
email?: string;
preferred_username?: string;
given_name?: string;
family_name?: string;
scope?: string;
realm_access?: JwtRealmAccess;
resource_access?: JwtResourceAccess;
iat: number;
exp: number;
iss: string;
aud?: JwtAudience;
[claim: string]: unknown;
}
@Injectable()
export class KeycloakJwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor() {
const baseUrl = process.env.KEYCLOAK_URL ?? 'https://auth.clipperia.com.br';
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKeyProvider: jwksRsa.passportJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri: `${baseUrl}/realms/clipperia/protocol/openid-connect/certs`,
}),
algorithms: ['RS256'],
issuer: `${baseUrl}/realms/clipperia`,
ignoreExpiration: false,
});
}
private readonly logger = new Logger(KeycloakJwtStrategy.name);
validate(payload: JwtPayload): JwtPayload {
try {
// Basic JWT info
this.logger.verbose('=== JWT Validation Start ===');
// Token metadata
this.logger.verbose(`Subject (sub): ${payload.sub}`);
this.logger.verbose(`Issuer (iss): ${payload.iss}`);
this.logger.verbose(`Audience (aud): ${JSON.stringify(payload.aud)}`);
this.logger.verbose(
`Issued At (iat): ${new Date(payload.iat * 1000).toISOString()}`,
);
this.logger.verbose(
`Expiration (exp): ${new Date(payload.exp * 1000).toISOString()}`,
);
// User info
this.logger.verbose('--- User Info ---');
this.logger.verbose(`Email: ${payload.email || 'N/A'}`);
this.logger.verbose(`Username: ${payload.preferred_username || 'N/A'}`);
this.logger.verbose(
`Name: ${payload.given_name || ''} ${payload.family_name || ''}`.trim() ||
'N/A',
);
// Realm roles
this.logger.verbose('--- Realm Access ---');
if (payload.realm_access?.roles?.length) {
payload.realm_access.roles.forEach((role: string) => {
this.logger.verbose(`- ${role}`);
});
} else {
this.logger.verbose('No realm roles found');
}
// Resource access
this.logger.verbose('--- Resource Access ---');
if (payload.resource_access) {
Object.entries(payload.resource_access).forEach(([resource, data]) => {
if (data?.roles?.length) {
this.logger.verbose(`${resource} roles:`);
data.roles.forEach((role: string) => {
this.logger.verbose(` - ${role}`);
});
}
});
} else {
this.logger.verbose('No resource access found');
}
// Token expiration check
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) {
const minutesAgo = Math.round((now - payload.exp) / 60);
this.logger.warn(`Token expired ${minutesAgo} minutes ago`);
throw new UnauthorizedException('Token expirado');
}
this.logger.verbose('=== JWT Validation Successful ===');
return payload;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.stack : String(error);
this.logger.error('JWT Validation Error:', errorMessage);
throw error;
}
return payload;
}
}