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

127 lines
4.3 KiB
TypeScript

import { randomBytes } from 'node:crypto'
import { decodeJwtPayload, refreshTokens } from './keycloak'
import type { H3Event } from 'h3'
const COOKIE_NAME = 'portal_sid'
const REFRESH_GUARD_MS = 30_000 // renova se restam <30s
export interface UserInfo {
sub: string
name?: string
preferred_username?: string
email?: string
realm_roles?: string[]
[key: string]: unknown
}
export interface SessionData {
accessToken: string
refreshToken: string
idToken: string
accessTokenExpiresAt: number
userInfo: UserInfo
createdAt: number
}
function genSid(): string {
return randomBytes(32).toString('base64url')
}
// ─── Operações de store ───────────────────────────────────────────────────────
export async function createSession(data: Omit<SessionData, 'createdAt'>): Promise<string> {
const sid = genSid()
const ttl = useRuntimeConfig().sessionTtlSeconds
const payload: SessionData = { ...data, createdAt: Date.now() }
await useRedis().set(`sess:${sid}`, JSON.stringify(payload), 'EX', ttl)
return sid
}
export async function readSession(sid: string | undefined): Promise<SessionData | null> {
if (!sid) return null
const raw = await useRedis().get(`sess:${sid}`)
if (!raw) return null
// sliding TTL — qualquer leitura estende a sessão
const ttl = useRuntimeConfig().sessionTtlSeconds
await useRedis().expire(`sess:${sid}`, ttl)
try {
return JSON.parse(raw) as SessionData
} catch {
return null
}
}
export async function updateSession(sid: string, patch: Partial<SessionData>): Promise<void> {
const current = await readSession(sid)
if (!current) return
const next: SessionData = { ...current, ...patch }
const ttl = useRuntimeConfig().sessionTtlSeconds
await useRedis().set(`sess:${sid}`, JSON.stringify(next), 'EX', ttl)
}
export async function deleteSession(sid: string | undefined): Promise<void> {
if (!sid) return
await useRedis().del(`sess:${sid}`)
}
// ─── Cookie helpers ───────────────────────────────────────────────────────────
export function setSessionCookie(event: H3Event, sid: string): void {
setCookie(event, COOKIE_NAME, sid, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: useRuntimeConfig().sessionTtlSeconds,
})
}
export function readSessionCookie(event: H3Event): string | undefined {
return getCookie(event, COOKIE_NAME)
}
export function clearSessionCookie(event: H3Event): void {
deleteCookie(event, COOKIE_NAME, { path: '/' })
}
// ─── Orquestração: token válido + refresh transparente ────────────────────────
export async function getValidAccessToken(sid: string): Promise<string | null> {
const session = await readSession(sid)
if (!session) return null
const now = Date.now()
if (session.accessTokenExpiresAt - now > REFRESH_GUARD_MS) {
return session.accessToken
}
try {
const tokens = await refreshTokens(session.refreshToken)
await updateSession(sid, {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token ?? session.idToken,
accessTokenExpiresAt: Date.now() + tokens.expires_in * 1000,
})
return tokens.access_token
} catch {
await deleteSession(sid)
return null
}
}
// ─── Helper: monta UserInfo a partir do access_token ─────────────────────────
export function userInfoFromAccessToken(accessToken: string): UserInfo {
const claims = decodeJwtPayload(accessToken)
const realmAccess = claims.realm_access as { roles?: string[] } | undefined
return {
sub: String(claims.sub ?? ''),
name: claims.name as string | undefined,
preferred_username: claims.preferred_username as string | undefined,
email: claims.email as string | undefined,
realm_roles: realmAccess?.roles,
}
}