first commit
This commit is contained in:
137
server/utils/jwt.ts
Normal file
137
server/utils/jwt.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
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' })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user