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

104 lines
3.1 KiB
TypeScript

export interface TokenResponse {
access_token: string
refresh_token: string
id_token: string
expires_in: number
refresh_expires_in?: number
token_type: string
scope?: string
}
function realmBase(): string {
const cfg = useRuntimeConfig()
return `${cfg.keycloakUrl}/realms/${cfg.keycloakRealm}`
}
export function buildAuthUrl(opts: {
codeChallenge: string
state: string
redirectUri: string
loginHint?: string
}): string {
const cfg = useRuntimeConfig()
const params = new URLSearchParams({
client_id: cfg.keycloakClientId,
redirect_uri: opts.redirectUri,
response_type: 'code',
scope: 'openid profile email',
code_challenge: opts.codeChallenge,
code_challenge_method: 'S256',
state: opts.state,
})
if (opts.loginHint) params.set('login_hint', opts.loginHint)
return `${realmBase()}/protocol/openid-connect/auth?${params.toString()}`
}
export async function exchangeCodeForTokens(opts: {
code: string
codeVerifier: string
redirectUri: string
}): Promise<TokenResponse> {
const cfg = useRuntimeConfig()
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: opts.code,
client_id: cfg.keycloakClientId,
client_secret: cfg.keycloakClientSecret,
redirect_uri: opts.redirectUri,
code_verifier: opts.codeVerifier,
})
return await $fetch<TokenResponse>(
`${realmBase()}/protocol/openid-connect/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
},
)
}
export async function refreshTokens(refreshToken: string): Promise<TokenResponse> {
const cfg = useRuntimeConfig()
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: cfg.keycloakClientId,
client_secret: cfg.keycloakClientSecret,
})
return await $fetch<TokenResponse>(
`${realmBase()}/protocol/openid-connect/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
},
)
}
export function buildLogoutUrl(opts: {
idTokenHint: string
postLogoutRedirectUri: string
}): string {
const params = new URLSearchParams({
id_token_hint: opts.idTokenHint,
post_logout_redirect_uri: opts.postLogoutRedirectUri,
})
return `${realmBase()}/protocol/openid-connect/logout?${params.toString()}`
}
/**
* Decodifica payload de JWT sem validar assinatura — uso server-only para
* extrair claims do id_token/access_token recém-emitido pelo Keycloak.
* NÃO USAR para validar tokens recebidos de terceiros.
*/
export function decodeJwtPayload(token: string): Record<string, unknown> {
const [, payload] = token.split('.')
if (!payload) return {}
try {
const json = Buffer.from(payload, 'base64url').toString('utf-8')
return JSON.parse(json) as Record<string, unknown>
} catch {
return {}
}
}