gabrielb 71e1a3f970 feat: portal Nuxt 3 com BFF + autenticação Keycloak (Fase 1)
Substitui o portal Vite+Vue puro por Nuxt 3 com BFF embutido (Nitro server
routes) e fluxo de autenticação Keycloak via token-handler pattern.

Server (BFF):
- server/api/auth/{login,callback,refresh,logout,me}.ts — Keycloak PKCE
- server/api/proxy/[...path].ts — proxy autenticado pro core-api com tenant
- server/utils/{session,keycloak,pkce,redis,tenant,prefeitura}.ts
- server/middleware/csrf.ts — Origin check + header X-Requested-With

Auth (token-handler pattern):
- JWT vive só server-side em Redis; cliente recebe cookie session-id opaco
- Refresh transparente quando access_token expira
- Multi-tenant via hostname → X-Municipio/X-Dominio injetados no proxy
- Realm dedicado: modumfiscal-portal-{env}

Frontend (Nuxt):
- src/pages/** (file-based routing) substitui src/views/
- Plugins SSR: prefeitura (bootstrap pré-hidratação) + auth (hidrata user via /api/auth/me)
- Composables useAuth, useApi, useLoginModal, useFocusLoginInput
- Modal global de login quando middleware /portal/** bloqueia
- Splash overlay no boot esconde flash do preset inicial pro tema dinâmico
- DocumentoInput bloqueia campo quando user autenticado (pré-preenche em certidão/IPTU)

Removidos:
- index.html, vite.config.js, src/main.js, src/router/
- src/config/apiClient.js (substituído por \$fetch via /api/proxy)
- src/services/{auth,prefeitura}Service.js (lógica migrada pra composables/plugins)
- src/mocks/ (não mais usado)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 20:31:19 -03:00

62 lines
1.8 KiB
TypeScript

import pkceChallenge from 'pkce-challenge'
import { randomBytes } from 'node:crypto'
import type { H3Event } from 'h3'
export interface PkceArtifacts {
codeVerifier: string
codeChallenge: string
state: string
}
export async function generatePkce(): Promise<PkceArtifacts> {
const { code_verifier, code_challenge } = await pkceChallenge()
const state = randomBytes(16).toString('base64url')
return {
codeVerifier: code_verifier,
codeChallenge: code_challenge,
state,
}
}
// ─── PKCE state em Redis (single-use, TTL curto) ─────────────────────────────
export interface PkceStateData {
codeVerifier: string
returnTo: string
createdAt: number
}
export async function savePkceState(state: string, data: PkceStateData): Promise<void> {
const ttl = useRuntimeConfig().pkceTtlSeconds
await useRedis().set(`pkce:${state}`, JSON.stringify(data), 'EX', ttl)
}
/**
* Lê e remove o state atomicamente — single-use, previne replay.
*/
export async function consumePkceState(state: string): Promise<PkceStateData | null> {
if (!state) return null
const key = `pkce:${state}`
const pipe = useRedis().multi()
pipe.get(key)
pipe.del(key)
const results = await pipe.exec()
const raw = results?.[0]?.[1] as string | null | undefined
if (!raw) return null
try {
return JSON.parse(raw) as PkceStateData
} catch {
return null
}
}
/**
* Computa o redirect_uri do callback a partir do request — coerente com o que foi enviado ao Keycloak.
*/
export function callbackUrlFromEvent(event: H3Event): string {
const host = getRequestHost(event, { xForwardedHost: true })
const proto = getRequestProtocol(event, { xForwardedProto: true })
return `${proto}://${host}/api/auth/callback`
}