135 lines
3.9 KiB
TypeScript
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' })
|
|
}
|
|
}
|