first commit

This commit is contained in:
2026-04-14 19:44:21 -05:00
commit 068576cf4b
36 changed files with 13680 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
import { handleLogin } from '../../utils/auth-service'
/**
* Endpoint de login do Auth Service.
* Recebe credenciais e retorna access/refresh token.
*/
export default defineEventHandler(async (event) => {
return handleLogin(event)
})

View File

@@ -0,0 +1,9 @@
import { handleRefresh } from '../../utils/auth-service'
/**
* Endpoint para renovar sessão com refresh token.
* Retorna novo access token e novo refresh token rotacionado.
*/
export default defineEventHandler(async (event) => {
return handleRefresh(event)
})

57
server/middleware/auth.ts Normal file
View File

@@ -0,0 +1,57 @@
import { createError, getRequestHeader, type H3Event } from 'h3'
import { getRouteRequirement } from '../utils/auth-rules'
import { verifyAccessToken } from '../utils/jwt'
/**
* Lê o header Authorization e extrai o token Bearer.
*
* @param event Evento HTTP atual.
* @returns Token JWT sem o prefixo `Bearer`.
* @throws {H3Error} Quando o header estiver ausente ou em formato inválido.
*/
function extractBearerToken(event: H3Event): string {
const authHeader = getRequestHeader(event, 'authorization')
if (!authHeader) {
throw createError({ statusCode: 401, statusMessage: 'Missing Authorization header' })
}
const [scheme, token] = authHeader.split(' ')
if (!scheme || !token || scheme.toLowerCase() !== 'bearer') {
throw createError({ statusCode: 401, statusMessage: 'Authorization must be Bearer token' })
}
return token.trim()
}
/**
* Middleware global de autenticação.
* Em rotas protegidas, valida o JWT e preenche `event.context.auth`.
*
* @param event Evento HTTP atual.
* @throws {H3Error} Quando houver falha de autenticação.
*/
export default defineEventHandler(async (event) => {
const routeRequirement = getRouteRequirement(event.method, event.path)
if (!routeRequirement) {
return
}
if (getRequestHeader(event, 'x-user-id')) {
throw createError({
statusCode: 400,
statusMessage: 'Custom identity headers are not allowed. Use JWT claims instead.'
})
}
const token = extractBearerToken(event)
const payload = await verifyAccessToken(event, token)
event.context.auth = {
id: payload.sub,
token
}
})

View File

@@ -0,0 +1,3 @@
import loginHandler from '../../api/auth/login.post'
export default loginHandler

View File

@@ -0,0 +1,3 @@
import refreshHandler from '../../api/auth/refresh.post'
export default refreshHandler

View File

@@ -0,0 +1,30 @@
import { createError, getRequestHeader } from 'h3'
import { requireAuthContext } from '../utils/require-auth'
/**
* Exemplo de orquestração A -> B.
* Reaproveita o mesmo Authorization para chamar `/profile/me`.
*/
export default defineEventHandler(async (event) => {
const auth = requireAuthContext(event)
const authorization = getRequestHeader(event, 'authorization')
if (!authorization) {
throw createError({ statusCode: 401, statusMessage: 'Missing Authorization header' })
}
const profileFromService = await $fetch('/profile/me', {
headers: {
Authorization: authorization
}
})
return {
sub_from_api_a: auth.id,
sub_from_api_b: profileFromService.id,
same_subject: profileFromService.id === auth.id,
profile: profileFromService
}
})

View File

@@ -0,0 +1,32 @@
import { createError } from 'h3'
import { prisma } from '../../utils/prisma'
import { requireAuthContext } from '../../utils/require-auth'
/**
* Retorna os dados do usuário autenticado com base no `sub` do JWT.
*/
export default defineEventHandler(async (event) => {
const auth = requireAuthContext(event)
const user = await prisma.user.findUnique({
where: { id: auth.id },
select: {
id: true,
email: true,
createdAt: true,
updatedAt: true
}
})
if (!user) {
throw createError({ statusCode: 404, statusMessage: 'Authenticated user not found' })
}
return {
id: user.id,
email: user.email,
created_at: user.createdAt.toISOString(),
updated_at: user.updatedAt.toISOString()
}
})

17
server/types/auth.ts Normal file
View File

@@ -0,0 +1,17 @@
export interface AuthContext {
id: string
token: string
}
export interface AccessTokenClaims {
sub: string
iss: string
aud: string
iat: number
exp: number
}
export interface AuthRouteRequirement {
method: string
path: string
}

View File

@@ -0,0 +1,93 @@
import { createError, type H3Event } from 'h3'
export interface AuthRuntimeConfig {
issuer: string
audience: string
accessTtlSec: number
refreshTtlSec: number
privateKeyPem: string
publicKeyPem: string
kid: string
refreshTokenPepper: string
}
/**
* Converte uma string para inteiro positivo e valida o resultado.
*
* @param value Valor recebido do runtime config.
* @param label Nome amigável do campo para mensagens de erro.
* @returns Número inteiro positivo.
* @throws {H3Error} Quando o valor não for um inteiro positivo.
*/
function parsePositiveInt(value: string, label: string): number {
const parsed = Number.parseInt(value, 10)
if (!Number.isFinite(parsed) || parsed <= 0) {
throw createError({
statusCode: 500,
statusMessage: `${label} must be a positive integer`
})
}
return parsed
}
/**
* Normaliza uma chave PEM salva em variável de ambiente.
* Troca `\\n` por quebra de linha real e remove espaços nas pontas.
*
* @param rawValue Valor bruto vindo do ambiente.
* @param label Nome amigável do campo para mensagens de erro.
* @returns Chave PEM pronta para uso.
* @throws {H3Error} Quando a chave estiver vazia.
*/
function normalizePem(rawValue: string, label: string): string {
const normalized = rawValue.replace(/\\n/g, '\n').trim()
if (!normalized) {
throw createError({
statusCode: 500,
statusMessage: `${label} is required`
})
}
return normalized
}
/**
* Lê e valida todas as configurações obrigatórias de autenticação.
*
* @param event Evento da requisição (opcional), usado para acessar runtime config.
* @returns Objeto com configurações de JWT e refresh token já validadas.
* @throws {H3Error} Quando algum campo obrigatório estiver ausente ou inválido.
*/
export function getAuthRuntimeConfig(event?: H3Event): AuthRuntimeConfig {
const runtimeConfig = useRuntimeConfig(event)
const issuer = String(runtimeConfig.jwtIssuer ?? '').trim()
const audience = String(runtimeConfig.jwtAudience ?? '').trim()
const kid = String(runtimeConfig.jwtKid ?? '').trim()
if (!issuer) {
throw createError({ statusCode: 500, statusMessage: 'JWT issuer is required' })
}
if (!audience) {
throw createError({ statusCode: 500, statusMessage: 'JWT audience is required' })
}
if (!kid) {
throw createError({ statusCode: 500, statusMessage: 'JWT kid is required' })
}
return {
issuer,
audience,
kid,
accessTtlSec: parsePositiveInt(String(runtimeConfig.jwtAccessTtlSec), 'JWT access TTL'),
refreshTtlSec: parsePositiveInt(String(runtimeConfig.jwtRefreshTtlSec), 'JWT refresh TTL'),
privateKeyPem: normalizePem(String(runtimeConfig.jwtPrivateKeyPem ?? ''), 'JWT private key'),
publicKeyPem: normalizePem(String(runtimeConfig.jwtPublicKeyPem ?? ''), 'JWT public key'),
refreshTokenPepper: String(runtimeConfig.refreshTokenPepper ?? '')
}
}

View File

@@ -0,0 +1,42 @@
import type { AuthRouteRequirement } from '../types/auth'
const PROTECTED_ROUTES: AuthRouteRequirement[] = [
{
method: 'GET',
path: '/profile/me'
},
{
method: 'GET',
path: '/dashboard'
}
]
/**
* Normaliza o caminho da requisição para facilitar a comparação de rotas.
* Remove query string e barra final extra.
*
* @param path Caminho original recebido na requisição.
* @returns Caminho em formato padronizado.
*/
function normalizePath(path: string): string {
const withoutQuery = path.split('?')[0] ?? '/'
const normalized = withoutQuery.replace(/\/+$/, '')
return normalized || '/'
}
/**
* Retorna a regra de autenticação da rota atual, quando existir.
*
* @param method Método HTTP da requisição.
* @param path Caminho da requisição.
* @returns Regra da rota protegida ou `null` quando a rota é pública.
*/
export function getRouteRequirement(method: string, path: string): AuthRouteRequirement | null {
const normalizedMethod = method.toUpperCase()
const normalizedPath = normalizePath(path)
return (
PROTECTED_ROUTES.find((route) => route.method === normalizedMethod && route.path === normalizedPath) ?? null
)
}

View File

@@ -0,0 +1,107 @@
import { createError, readBody, type H3Event } from 'h3'
import { signAccessToken } from './jwt'
import { getAuthRuntimeConfig } from './auth-config'
import { verifyPassword } from './password'
import { prisma } from './prisma'
import { issueRefreshToken, rotateRefreshToken } from './refresh-token'
interface LoginBody {
email?: unknown
password?: unknown
}
interface RefreshBody {
refresh_token?: unknown
}
/**
* Valida e normaliza email/senha enviados no login.
*
* @param body Corpo bruto da requisição de login.
* @returns Email normalizado e senha pronta para validação.
* @throws {H3Error} Quando email ou senha não forem informados.
*/
function parseCredentialBody(body: LoginBody) {
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : ''
const password = typeof body.password === 'string' ? body.password : ''
if (!email || !password) {
throw createError({ statusCode: 400, statusMessage: 'email and password are required' })
}
return { email, password }
}
/**
* Valida o refresh token enviado no corpo da requisição.
*
* @param body Corpo bruto da requisição de refresh.
* @returns Refresh token em formato de string.
* @throws {H3Error} Quando `refresh_token` não for informado.
*/
function parseRefreshBody(body: RefreshBody): string {
const refreshToken = typeof body.refresh_token === 'string' ? body.refresh_token.trim() : ''
if (!refreshToken) {
throw createError({ statusCode: 400, statusMessage: 'refresh_token is required' })
}
return refreshToken
}
/**
* Executa o fluxo de login:
* valida credenciais, gera access token e emite refresh token.
*
* @param event Evento HTTP da requisição.
* @returns Resposta padrão de autenticação para o cliente.
* @throws {H3Error} Quando as credenciais forem inválidas ou faltarem dados.
*/
export async function handleLogin(event: H3Event) {
const config = getAuthRuntimeConfig(event)
const body = await readBody<LoginBody>(event)
const { email, password } = parseCredentialBody(body ?? {})
const user = await prisma.user.findUnique({ where: { email } })
if (!user || !verifyPassword(password, user.passwordHash)) {
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
}
const accessToken = await signAccessToken(event, { sub: user.id })
const refreshToken = await issueRefreshToken(event, user.id)
return {
access_token: accessToken,
refresh_token: refreshToken.token,
token_type: 'Bearer',
expires_in: config.accessTtlSec
}
}
/**
* Executa o fluxo de refresh:
* valida o refresh token, rotaciona e retorna novo access token.
*
* @param event Evento HTTP da requisição.
* @returns Novo par de tokens para continuar a sessão.
* @throws {H3Error} Quando o refresh token for inválido ou ausente.
*/
export async function handleRefresh(event: H3Event) {
const config = getAuthRuntimeConfig(event)
const body = await readBody<RefreshBody>(event)
const incomingRefreshToken = parseRefreshBody(body ?? {})
const rotated = await rotateRefreshToken(event, incomingRefreshToken)
const accessToken = await signAccessToken(event, { sub: rotated.user.id })
return {
access_token: accessToken,
refresh_token: rotated.token,
token_type: 'Bearer',
expires_in: config.accessTtlSec
}
}

137
server/utils/jwt.ts Normal file
View 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' })
}
}

41
server/utils/password.ts Normal file
View File

@@ -0,0 +1,41 @@
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
const PASSWORD_HASH_PREFIX = 'scrypt'
const DERIVED_KEY_LENGTH = 64
/**
* Gera hash de senha com scrypt e salt aleatório.
*
* @param password Senha em texto puro.
* @returns Hash no formato `scrypt$salt$hash`.
*/
export function hashPassword(password: string): string {
const salt = randomBytes(16).toString('base64url')
const derivedKey = scryptSync(password, salt, DERIVED_KEY_LENGTH).toString('base64url')
return `${PASSWORD_HASH_PREFIX}$${salt}$${derivedKey}`
}
/**
* Compara senha em texto puro com hash salvo no banco.
*
* @param password Senha informada no login.
* @param encodedHash Hash armazenado no banco.
* @returns `true` quando a senha confere; caso contrário, `false`.
*/
export function verifyPassword(password: string, encodedHash: string): boolean {
const [prefix, salt, storedHash] = encodedHash.split('$')
if (prefix !== PASSWORD_HASH_PREFIX || !salt || !storedHash) {
return false
}
const derivedKey = scryptSync(password, salt, DERIVED_KEY_LENGTH)
const storedKeyBuffer = Buffer.from(storedHash, 'base64url')
if (derivedKey.length !== storedKeyBuffer.length) {
return false
}
return timingSafeEqual(derivedKey, storedKeyBuffer)
}

11
server/utils/prisma.ts Normal file
View File

@@ -0,0 +1,11 @@
import { PrismaClient } from '@prisma/client'
type PrismaGlobal = typeof globalThis & { prisma?: PrismaClient }
const globalForPrisma = globalThis as PrismaGlobal
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}

View File

@@ -0,0 +1,118 @@
import { createHash, randomBytes } from 'node:crypto'
import { createError, type H3Event } from 'h3'
import { getAuthRuntimeConfig } from './auth-config'
import { prisma } from './prisma'
export interface IssuedRefreshToken {
token: string
expiresAt: Date
expiresInSec: number
recordId: string
}
/**
* Gera um hash seguro do refresh token usando pepper do ambiente.
* O token bruto nunca é salvo no banco.
*
* @param rawToken Refresh token em texto puro.
* @param pepper Segredo adicional do servidor.
* @returns Hash SHA-256 do token.
*/
export function hashRefreshToken(rawToken: string, pepper: string): string {
return createHash('sha256').update(`${rawToken}${pepper}`).digest('hex')
}
/**
* Cria um refresh token aleatório para entregar ao cliente.
*
* @returns Token aleatório em formato URL-safe.
*/
function generateRawRefreshToken(): string {
return randomBytes(48).toString('base64url')
}
/**
* Emite e persiste um novo refresh token para um usuário.
*
* @param event Evento HTTP usado para ler config.
* @param userId ID do usuário dono do token.
* @returns Metadados do refresh token emitido.
*/
export async function issueRefreshToken(event: H3Event, userId: string): Promise<IssuedRefreshToken> {
const config = getAuthRuntimeConfig(event)
const token = generateRawRefreshToken()
const tokenHash = hashRefreshToken(token, config.refreshTokenPepper)
const expiresAt = new Date(Date.now() + config.refreshTtlSec * 1000)
const created = await prisma.refreshToken.create({
data: {
userId,
tokenHash,
expiresAt
}
})
return {
token,
expiresAt,
expiresInSec: config.refreshTtlSec,
recordId: created.id
}
}
/**
* Rotaciona um refresh token válido:
* cria um novo token e marca o antigo como revogado.
*
* @param event Evento HTTP usado para ler config.
* @param rawRefreshToken Token atual enviado pelo cliente.
* @returns Dados do novo refresh token e usuário autenticado.
* @throws {H3Error} Quando o token for inválido, revogado ou expirado.
*/
export async function rotateRefreshToken(event: H3Event, rawRefreshToken: string) {
const config = getAuthRuntimeConfig(event)
const tokenHash = hashRefreshToken(rawRefreshToken, config.refreshTokenPepper)
const existing = await prisma.refreshToken.findUnique({
where: { tokenHash },
include: { user: true }
})
if (!existing || existing.revokedAt || existing.expiresAt.getTime() <= Date.now()) {
throw createError({ statusCode: 401, statusMessage: 'Invalid refresh token' })
}
const newRawRefreshToken = generateRawRefreshToken()
const newTokenHash = hashRefreshToken(newRawRefreshToken, config.refreshTokenPepper)
const newExpiresAt = new Date(Date.now() + config.refreshTtlSec * 1000)
const rotated = await prisma.$transaction(async (tx) => {
const created = await tx.refreshToken.create({
data: {
userId: existing.userId,
tokenHash: newTokenHash,
expiresAt: newExpiresAt
}
})
await tx.refreshToken.update({
where: { id: existing.id },
data: {
revokedAt: new Date(),
replacedById: created.id
}
})
return created
})
return {
user: existing.user,
token: newRawRefreshToken,
expiresAt: rotated.expiresAt,
expiresInSec: config.refreshTtlSec,
recordId: rotated.id,
replacedTokenId: existing.id
}
}

View File

@@ -0,0 +1,18 @@
import { createError, type H3Event } from 'h3'
import type { AuthContext } from '../types/auth'
/**
* Garante que o middleware de autenticação já preencheu `event.context.auth`.
*
* @param event Evento HTTP atual.
* @returns Contexto autenticado com ID do usuário e token.
* @throws {H3Error} Quando a requisição não estiver autenticada.
*/
export function requireAuthContext(event: H3Event): AuthContext {
if (!event.context.auth) {
throw createError({ statusCode: 401, statusMessage: 'Authentication required' })
}
return event.context.auth
}