Files

135 lines
3.9 KiB
TypeScript

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<LoadedSigningKeys> {
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<AccessTokenClaims, 'sub'>
): Promise<string> {
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<AccessTokenClaims> {
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' })
}
}