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>
127 lines
4.3 KiB
TypeScript
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,
|
|
}
|
|
}
|