122 lines
3.8 KiB
TypeScript
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;
|
|
}
|
|
}
|