import { createError, type H3Event } from 'h3' import { SignJWT, importPKCS8, importSPKI, jwtVerify, type JWTPayload } from 'jose' import type { AccessTokenClaims } from '../types/auth' import { getAuthRuntimeConfig } from './auth-config' interface LoadedSigningKeys { privateKey: CryptoKey publicKey: CryptoKey keyFingerprint: string } let cachedKeys: LoadedSigningKeys | null = null /** * Gera uma assinatura simples para identificar quando as chaves mudaram. * Isso evita reutilizar chaves antigas em memória. * * @param privateKeyPem Chave privada em PEM. * @param publicKeyPem Chave pública em PEM. * @param kid Identificador da chave. * @returns Texto usado para comparar cache de chaves. */ function keysFingerprint(privateKeyPem: string, publicKeyPem: string, kid: string): string { return `${kid}:${privateKeyPem.length}:${publicKeyPem.length}` } /** * Carrega as chaves JWT e mantém cache em memória para performance. * * @param event Evento HTTP usado para ler config de runtime. * @returns Chaves de assinatura/verificação prontas para uso. * @throws {H3Error} Quando as chaves PEM não puderem ser carregadas. */ async function loadSigningKeys(event: H3Event): Promise { const config = getAuthRuntimeConfig(event) const fingerprint = keysFingerprint(config.privateKeyPem, config.publicKeyPem, config.kid) if (cachedKeys && cachedKeys.keyFingerprint === fingerprint) { return cachedKeys } try { const [privateKey, publicKey] = await Promise.all([ importPKCS8(config.privateKeyPem, 'RS256'), importSPKI(config.publicKeyPem, 'RS256') ]) cachedKeys = { privateKey, publicKey, keyFingerprint: fingerprint } return cachedKeys } catch { throw createError({ statusCode: 500, statusMessage: 'Failed to load JWT signing keys' }) } } /** * Assina um novo access token RS256 para o usuário autenticado. * * @param event Evento HTTP usado para ler config de runtime. * @param payload Dados mínimos do token (apenas `sub`). * @returns JWT assinado em formato string. */ export async function signAccessToken(event: H3Event, payload: Pick): Promise { const config = getAuthRuntimeConfig(event) const keys = await loadSigningKeys(event) return new SignJWT({}) .setProtectedHeader({ alg: 'RS256', kid: config.kid, typ: 'JWT' }) .setSubject(payload.sub) .setIssuer(config.issuer) .setAudience(config.audience) .setIssuedAt() .setExpirationTime(`${config.accessTtlSec}s`) .sign(keys.privateKey) } /** * Valida um access token recebido em rota protegida. * Confere assinatura, issuer, audience, exp e formato de claims. * * @param event Evento HTTP usado para ler config de runtime. * @param token JWT recebido no header Authorization. * @returns Claims normalizadas do token válido. * @throws {H3Error} Quando o token for inválido ou expirado. */ export async function verifyAccessToken(event: H3Event, token: string): Promise { const config = getAuthRuntimeConfig(event) const keys = await loadSigningKeys(event) try { const { payload } = await jwtVerify(token, keys.publicKey, { issuer: config.issuer, audience: config.audience, algorithms: ['RS256'] }) const claims = payload as JWTPayload & { sub?: unknown iss?: unknown aud?: unknown } if (typeof claims.sub !== 'string' || !claims.sub.trim()) { throw new Error('Invalid sub claim') } if (typeof claims.iat !== 'number' || typeof claims.exp !== 'number') { throw new Error('Invalid temporal claims') } const audience = Array.isArray(claims.aud) ? claims.aud[0] : claims.aud return { sub: claims.sub, iss: String(claims.iss), aud: String(audience), iat: claims.iat, exp: claims.exp } } catch { throw createError({ statusCode: 401, statusMessage: 'Invalid or expired access token' }) } }