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>
104 lines
3.1 KiB
TypeScript
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 {}
|
|
}
|
|
}
|