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,187 @@
# Plano minimo de implementacao — comunicacao entre servicos com JWT
## Objetivo
Implementar comunicacao minima entre servicos distribuidos onde:
- o **Auth Service** autentica o usuario
- o **Auth Service** emite o **JWT**
- os outros servicos **validam o JWT localmente**
- um servico pode chamar outro **em nome do usuario**, repassando o mesmo token
- cada servico identifica o usuario autenticado pelo `sub`
---
## Arquitetura minima
### Auth Service
Responsavel por:
- login
- refresh token
- emissao do JWT
- distribuicao da chave publica para validacao nos demais servicos
### Outros servicos
Responsaveis por:
- receber `Authorization: Bearer <token>`
- validar JWT localmente
- extrair claims
- montar contexto do usuario autenticado
### Comunicacao entre servicos
Quando um servico chamar outro em nome do usuario:
- repassar o mesmo header `Authorization`
---
## Contrato minimo do JWT
Exemplo:
```json
{
"sub": "uuid-do-usuario",
"iss": "https://auth.seusistema.com",
"aud": "internal-apis",
"iat": 1776110000,
"exp": 1776110900
}
```
### Claims minimas
- `sub`: identificador do usuario
- `iss`: emissor
- `aud`: audiencia
- `iat`: emissao
- `exp`: expiracao
### Regra importante
O identificador confiavel do usuario deve ser sempre o **`sub`**.
Nao confiar em:
- `user_id` no body
- `x-user-id` em header customizado
- qualquer identificador separado do token
---
## Escolhas tecnicas minimas
### Algoritmo
- `RS256`
### Assinatura
- Auth assina com chave privada
- demais servicos validam com chave publica
### Tempo de vida (MVP)
- access token: 15 minutos
- refresh token: 7 dias
---
## Endpoints minimos do Auth
```http
POST /auth/login
POST /auth/refresh
```
### Login
Retorna:
```json
{
"access_token": "jwt_aqui",
"refresh_token": "refresh_aqui",
"token_type": "Bearer",
"expires_in": 900
}
```
### Refresh
Recebe refresh token e retorna novo access token + novo refresh token.
## Middleware minimo em cada servico
Passos:
1. ler `Authorization`
2. extrair bearer token
3. validar assinatura
4. validar `exp`
5. validar `iss`
6. validar `aud`
7. extrair claims
8. montar usuario autenticado no contexto da requisicao
Resultado esperado no contexto:
```json
{
"id": "uuid-do-usuario"
}
```
---
## Rotas protegidas minimas para teste
- `GET /profile/me`
- `GET /dashboard`
Cada rota deve:
- exigir token valido
- extrair `sub`
- retornar dados do usuario autenticado
---
## Ordem minima de implementacao
1. definir contrato do token
2. gerar chaves
3. implementar `login`, `refresh`
4. emitir JWT corretamente
5. implementar middleware JWT em 1 servico consumidor
6. proteger 1 rota simples
7. replicar middleware nos demais servicos
8. implementar propagacao de `Authorization` entre servicos
---
## Criterios de sucesso do MVP
1. login retorna `access_token`
2. acesso direto a rota protegida funciona
3. token invalido retorna `401`
4. token expirado retorna `401`
5. chamada A -> B preserva o mesmo `sub`
---
## Fora de escopo agora
- blacklist de tokens
- revogacao imediata global
- introspection por request
- machine-to-machine token
- gateway avancado
---
## Resumo executivo
O minimo para funcionar:
1. Auth emitir JWT assinado
2. servicos validarem localmente
3. `sub` como identificador confiavel
4. propagacao do mesmo bearer token entre servicos

15
.env.example Normal file
View File

@@ -0,0 +1,15 @@
# Postgres
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/sistema_auth?schema=public"
DIRECT_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/sistema_auth?schema=public"
# JWT contract
JWT_ISSUER="https://auth.local"
JWT_AUDIENCE="internal-apis"
JWT_ACCESS_TTL_SEC="900"
JWT_REFRESH_TTL_SEC="604800"
JWT_KID="auth-key-1"
JWT_PRIVATE_KEY_PEM="<replace-with-escaped-pem>"
JWT_PUBLIC_KEY_PEM="<replace-with-escaped-pem>"
# Optional hardening
REFRESH_TOKEN_PEPPER="change-me"

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

132
README.md Normal file
View File

@@ -0,0 +1,132 @@
# Sistema Auth Central (Nuxt + Prisma)
MVP de autenticação central para serviços distribuídos.
## O que este projeto entrega
- Auth Service com `login` e `refresh`
- JWT assinado em `RS256` com contrato fixo
- Middleware de validação JWT local
- Serviço consumidor de referência (`/profile/me`)
- Fluxo A -> B (`/dashboard` chama `/profile/me` com o mesmo Bearer token)
## Stack
- Nuxt 4 (Nitro server routes)
- Prisma + Postgres
- JOSE (JWT)
## Setup
1. Instale dependências:
```bash
npm install
```
2. Gere chaves RSA para JWT:
```bash
npm run jwt:keys
```
Distribua o valor de `JWT_PUBLIC_KEY_PEM` para todos os serviços consumidores.
3. Crie `.env` a partir de `.env.example` e preencha os valores.
Se usar Postgres remoto com pool/proxy (ex.: Railway), use `DIRECT_DATABASE_URL` com conexão direta para migrations.
4. Execute migrações do Prisma:
```bash
npm run prisma:migrate:dev -- --name init
```
5. Rode seed para usuários de teste:
```bash
npm run prisma:seed
```
6. Inicie a aplicação:
```bash
npm run dev
```
## Usuários de seed
- `student@example.com` / `student123`
- `admin@example.com` / `admin123`
- `limited@example.com` / `limited123`
## Estrutura da tabela `User`
A tabela `User` possui apenas:
- `id`
- `email`
- `passwordHash`
- `createdAt`
- `updatedAt`
## Endpoints
- `POST /auth/login`
- `POST /auth/refresh`
- `GET /profile/me` (protegida)
- `GET /dashboard` (protegida, chama `/profile/me`)
## Guia para serviços consumidores
- `docs/GUIA_VALIDACAO_JWT_SERVICOS_CONSUMIDORES.md`
## Contrato do Access Token
Claims obrigatórias:
- `sub`
- `iss`
- `aud`
- `iat`
- `exp`
A identidade confiável do usuário é sempre o `sub`.
## Teste rápido (curl)
### 1) Login
```bash
curl -X POST http://localhost:3000/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"student@example.com","password":"student123"}'
```
### 2) Rota protegida
```bash
curl http://localhost:3000/profile/me \
-H "Authorization: Bearer <access_token>"
```
### 3) Chamada entre serviços (A -> B)
```bash
curl http://localhost:3000/dashboard \
-H "Authorization: Bearer <access_token>"
```
### 4) Refresh
```bash
curl -X POST http://localhost:3000/auth/refresh \
-H 'Content-Type: application/json' \
-d '{"refresh_token":"<refresh_token>"}'
```
## Scripts úteis
- `npm run prisma:generate`
- `npm run prisma:migrate:dev`
- `npm run prisma:seed`
- `npm run jwt:keys`

6
app/app.vue Normal file
View File

@@ -0,0 +1,6 @@
<template>
<div>
<NuxtRouteAnnouncer />
<NuxtWelcome />
</div>
</template>

View File

@@ -0,0 +1,134 @@
# Guia didatico: validar JWT nos servicos consumidores (sem JWKS)
Este guia mostra a forma mais simples de integrar os servicos:
1. Auth assina o token com `JWT_PRIVATE_KEY_PEM`
2. cada servico consumidor valida localmente com `JWT_PUBLIC_KEY_PEM`
## 1) Configuracao em cada servico consumidor
Cada servico precisa ter no `.env`:
```env
JWT_ISSUER="https://auth.local"
JWT_AUDIENCE="internal-apis"
JWT_PUBLIC_KEY_PEM="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
```
Importante:
- o `JWT_PUBLIC_KEY_PEM` deve ser exatamente a chave publica do Auth
- mantenha os `\n` literais no `.env`
## 2) O que validar
Para toda rota protegida:
1. ler `Authorization`
2. extrair `Bearer <token>`
3. validar assinatura RS256 com `JWT_PUBLIC_KEY_PEM`
4. validar `iss`, `aud` e `exp`
5. validar se `sub` existe
6. montar `request.auth = { id: sub, token }`
Se algo falhar, retornar `401`.
## 3) Exemplo pratico (Node/Express + jose)
```ts
import express from 'express'
import { importSPKI, jwtVerify } from 'jose'
const app = express()
const ISSUER = process.env.JWT_ISSUER!
const AUDIENCE = process.env.JWT_AUDIENCE!
const PUBLIC_KEY_PEM = process.env.JWT_PUBLIC_KEY_PEM!.replace(/\\n/g, '\n')
let publicKeyPromise: ReturnType<typeof importSPKI> | null = null
function getPublicKey() {
if (!publicKeyPromise) {
publicKeyPromise = importSPKI(PUBLIC_KEY_PEM, 'RS256')
}
return publicKeyPromise
}
function authMiddleware() {
return async (req, res, next) => {
try {
const authHeader = req.header('authorization')
if (!authHeader) {
return res.status(401).json({ message: 'Missing Authorization header' })
}
const [scheme, token] = authHeader.split(' ')
if (!scheme || !token || scheme.toLowerCase() !== 'bearer') {
return res.status(401).json({ message: 'Authorization must be Bearer token' })
}
const publicKey = await getPublicKey()
const { payload } = await jwtVerify(token, publicKey, {
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ['RS256']
})
if (typeof payload.sub !== 'string' || !payload.sub.trim()) {
return res.status(401).json({ message: 'Invalid token claims' })
}
req.auth = {
id: payload.sub,
token
}
return next()
} catch {
return res.status(401).json({ message: 'Invalid or expired access token' })
}
}
}
app.get('/profile/me', authMiddleware(), (req, res) => {
return res.json({ userId: req.auth.id })
})
```
## 4) Propagacao entre servicos (A -> B)
Quando API A chamar API B em nome do usuario:
- repasse o mesmo `Authorization: Bearer <token>`
- API B valida esse token com `JWT_PUBLIC_KEY_PEM`
- API B usa `sub` como identidade confiavel
```ts
const authHeader = req.header('authorization')
const response = await fetch('http://courses-service/courses/me', {
headers: { Authorization: authHeader! }
})
```
## 5) Checklist rapido
- todos os servicos com o mesmo `JWT_PUBLIC_KEY_PEM`
- middleware valida assinatura + `iss` + `aud` + `exp`
- `sub` obrigatorio
- retorna `401` para token ausente/invalido/expirado
- usa `sub` como ID confiavel
## 6) Trade-off dessa abordagem
Vantagem:
- muito simples para implementar e explicar
Desvantagem:
- quando trocar a chave publica, precisa atualizar o `.env` de todos os servicos consumidores

9
h3.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import type { AuthContext } from './server/types/auth'
declare module 'h3' {
interface H3EventContext {
auth?: AuthContext
}
}
export {}

15
lib/prisma.ts Normal file
View File

@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client'
const prismaClientSingleton = () => {
return new PrismaClient()
}
declare const globalThis: {
prismaGlobal: ReturnType<typeof prismaClientSingleton>;
} & typeof global;
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
export default prisma
if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma

22
nuxt.config.ts Normal file
View File

@@ -0,0 +1,22 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
modules: ['@nuxtjs/tailwindcss', '@pinia/nuxt', '@prisma/nuxt'],
runtimeConfig: {
databaseUrl: process.env.DATABASE_URL,
jwtIssuer: process.env.JWT_ISSUER ?? 'https://auth.local',
jwtAudience: process.env.JWT_AUDIENCE ?? 'internal-apis',
jwtAccessTtlSec: process.env.JWT_ACCESS_TTL_SEC ?? '900',
jwtRefreshTtlSec: process.env.JWT_REFRESH_TTL_SEC ?? '604800',
jwtPrivateKeyPem: process.env.JWT_PRIVATE_KEY_PEM ?? '',
jwtPublicKeyPem: process.env.JWT_PUBLIC_KEY_PEM ?? '',
jwtKid: process.env.JWT_KID ?? 'auth-key-1',
refreshTokenPepper: process.env.REFRESH_TOKEN_PEPPER ?? ''
},
vite: {
optimizeDeps: {
include: ['@vue/devtools-core', '@vue/devtools-kit']
}
}
})

12207
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "sistema",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"prisma:generate": "prisma generate",
"prisma:migrate:dev": "prisma migrate dev",
"prisma:seed": "node prisma/seed.mjs",
"jwt:keys": "node scripts/generate-jwt-keys.mjs"
},
"prisma": {
"seed": "node prisma/seed.mjs"
},
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",
"@pinia/nuxt": "^0.11.3",
"@prisma/client": "^6.16.2",
"@prisma/nuxt": "^0.3.0",
"jose": "^6.2.2",
"nuxt": "^4.4.2",
"prisma": "^6.16.2",
"vue": "^3.5.32",
"vue-router": "^5.0.4"
},
"devDependencies": {
"@types/node": "^25.6.0"
}
}

View File

@@ -0,0 +1 @@
# Keep Prisma migrations directory in VCS

View File

@@ -0,0 +1,45 @@
-- CreateTable
CREATE TABLE "public"."User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."RefreshToken" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"revokedAt" TIMESTAMP(3),
"replacedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "public"."User"("email");
-- CreateIndex
CREATE INDEX "User_email_idx" ON "public"."User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "RefreshToken_tokenHash_key" ON "public"."RefreshToken"("tokenHash");
-- CreateIndex
CREATE INDEX "RefreshToken_userId_idx" ON "public"."RefreshToken"("userId");
-- CreateIndex
CREATE INDEX "RefreshToken_expiresAt_idx" ON "public"."RefreshToken"("expiresAt");
-- AddForeignKey
ALTER TABLE "public"."RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."RefreshToken" ADD CONSTRAINT "RefreshToken_replacedById_fkey" FOREIGN KEY ("replacedById") REFERENCES "public"."RefreshToken"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

38
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,38 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_DATABASE_URL")
}
model User {
id String @id @default(uuid())
email String @unique
passwordHash String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
refreshTokens RefreshToken[]
@@index([email])
}
model RefreshToken {
id String @id @default(uuid())
userId String
tokenHash String @unique
expiresAt DateTime
revokedAt DateTime?
replacedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
replacedBy RefreshToken? @relation("RefreshRotation", fields: [replacedById], references: [id])
replaces RefreshToken[] @relation("RefreshRotation")
@@index([userId])
@@index([expiresAt])
}

52
prisma/seed.mjs Normal file
View File

@@ -0,0 +1,52 @@
import { PrismaClient } from '@prisma/client'
import { randomBytes, scryptSync } from 'node:crypto'
const prisma = new PrismaClient()
function hashPassword(password) {
const salt = randomBytes(16).toString('base64url')
const derivedKey = scryptSync(password, salt, 64).toString('base64url')
return `scrypt$${salt}$${derivedKey}`
}
async function upsertUser({ email, password }) {
const passwordHash = hashPassword(password)
await prisma.user.upsert({
where: { email },
update: {
passwordHash
},
create: {
email,
passwordHash
}
})
}
async function main() {
await upsertUser({
email: 'student@example.com',
password: 'student123'
})
await upsertUser({
email: 'admin@example.com',
password: 'admin123'
})
await upsertUser({
email: 'limited@example.com',
password: 'limited123'
})
}
main()
.then(async () => {
await prisma.$disconnect()
})
.catch(async (error) => {
console.error('Seed failed:', error)
await prisma.$disconnect()
process.exit(1)
})

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

2
public/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-Agent: *
Disallow:

View File

@@ -0,0 +1,24 @@
import { generateKeyPairSync, randomUUID } from 'node:crypto'
const { privateKey, publicKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem'
}
})
const kid = `auth-${randomUUID()}`
function toEnvValue(pem) {
return pem.trim().replace(/\n/g, '\\n')
}
console.log('# Copy these values to your .env file')
console.log(`JWT_KID="${kid}"`)
console.log(`JWT_PRIVATE_KEY_PEM="${toEnvValue(privateKey)}"`)
console.log(`JWT_PUBLIC_KEY_PEM="${toEnvValue(publicKey)}"`)

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
}

4
tsconfig.json Normal file
View File

@@ -0,0 +1,4 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}