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

73 lines
2.6 KiB
TypeScript

/**
* Proxy genérico para o core-api.
*
* Fluxo:
* 1. Lê cookie de sessão → busca tokens em Redis (refresh transparente se expirado)
* 2. Resolve o tenant (dominio) a partir do hostname
* 3. Busca codigoMunicipio via /publico/prefeitura/{dominio} (cacheado em Redis)
* 4. Forward para core-api com Authorization + X-Municipio + X-Dominio
*
* Bypass: rotas /api/v1/publico/** podem ser acessadas sem sessão.
*/
export default defineEventHandler(async (event) => {
const path = getRouterParam(event, 'path') ?? ''
const isPublico = path.startsWith('publico/')
let accessToken: string | null = null
if (!isPublico) {
const sid = readSessionCookie(event)
if (!sid) {
throw createError({ statusCode: 401, statusMessage: 'Sem sessão' })
}
accessToken = await getValidAccessToken(sid)
if (!accessToken) {
clearSessionCookie(event)
throw createError({ statusCode: 401, statusMessage: 'Sessão expirada' })
}
}
const dominio = tenantFromEvent(event)
const prefeitura = await fetchPrefeituraInfo(dominio)
if (!prefeitura) {
throw createError({ statusCode: 400, statusMessage: `Tenant '${dominio}' não encontrado` })
}
const cfg = useRuntimeConfig()
const url = `${cfg.coreApiUrl}/api/v1/${path}`
const query = getQuery(event)
const method = event.method.toUpperCase()
const body = ['GET', 'HEAD'].includes(method) ? undefined : await readRawBody(event)
const headers: Record<string, string> = {
'X-Municipio': String(prefeitura.codigoMunicipio),
'X-Dominio': prefeitura.dominio,
}
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
const contentType = getHeader(event, 'content-type')
if (contentType) headers['Content-Type'] = contentType
try {
const res = await $fetch.raw(url, {
method: method as 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
query,
body,
headers,
responseType: 'stream',
})
setResponseStatus(event, res.status)
for (const [name, value] of res.headers.entries()) {
if (name === 'transfer-encoding') continue
setResponseHeader(event, name, value)
}
return res._data
} catch (err: unknown) {
const fetchErr = err as { response?: { status?: number; _data?: unknown } }
if (fetchErr.response) {
setResponseStatus(event, fetchErr.response.status ?? 500)
return fetchErr.response._data
}
throw err
}
})