From 0ce317a8f38dddd8199b4841df01d9b8b3270001 Mon Sep 17 00:00:00 2001 From: Marcos Reifonas Date: Tue, 18 Aug 2026 10:23:29 +0000 Subject: [PATCH] fix(logto): adicionar PKCE + rota /callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Logto EXIGE PKCE, sem isso retorna 400 invalid_request - client.ts: restaura generatePkce() com crypto.subtle - Adiciona /callback route + Callback.tsx que processa ?code= - useAuth expõe handleCallback() no contexto - nginx já tem fallback SPA, então /callback carrega index.html e React Router mostra a página de Callback --- src/config/routeConfig.ts | 5 ++ src/hooks/useAuth.tsx | 6 +++ src/lib/logto/client.ts | 111 ++++++++++++++++++++++++++------------ src/pages/Callback.tsx | 107 ++++++++++++++++++++++++++++++++++++ 4 files changed, 195 insertions(+), 34 deletions(-) create mode 100644 src/pages/Callback.tsx diff --git a/src/config/routeConfig.ts b/src/config/routeConfig.ts index 8ed5e5f..2cd29fa 100644 --- a/src/config/routeConfig.ts +++ b/src/config/routeConfig.ts @@ -24,6 +24,11 @@ export const routeConfig: RouteDefinition[] = [ component: () => import('@/pages/Auth'), guard: 'public', }, + { + path: '/callback', + component: () => import('@/pages/Callback'), + guard: 'public', + }, // ── Dashboard ──────────────────────────────────────────── { diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx index 93e7a0e..846ce9c 100644 --- a/src/hooks/useAuth.tsx +++ b/src/hooks/useAuth.tsx @@ -28,6 +28,7 @@ export interface UseAuthReturn { signUp: (email: string, password: string) => Promise<{ error: unknown }>; signOut: () => Promise; updatePassword: (password: string) => Promise<{ error: unknown }>; + handleCallback: () => Promise; } const AuthContext = createContext(undefined); @@ -92,6 +93,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { }; }, []); + const handleCallbackFn = useCallback(async (): Promise => { + return await handleCallback(); + }, []); + const value: UseAuthReturn = { user, loading, @@ -101,6 +106,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { signUp, signOut, updatePassword, + handleCallback: handleCallbackFn, }; return {children}; diff --git a/src/lib/logto/client.ts b/src/lib/logto/client.ts index 41ffc25..934c80f 100644 --- a/src/lib/logto/client.ts +++ b/src/lib/logto/client.ts @@ -1,10 +1,12 @@ // Client Logto puro (sem @logto/react, sem instalar pacotes) -// Versão simplificada - usa redirect sem PKCE (Logto aceita) +// Usa PKCE (Logto exige pra apps SPA) const LOGTO_ENDPOINT = import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001'; -const APP_ID = import.meta.env.VITE_LOGTO_APP_ID; -const REDIRECT_URI = import.meta.env.VITE_LOGTO_REDIRECT_URI || window.location.origin + '/callback'; -const POST_LOGOUT_REDIRECT_URI = import.meta.env.VITE_LOGTO_POST_LOGOUT_REDIRECT_URI || window.location.origin; +const APP_ID = import.meta.env.VITE_LOGTO_APP_ID || ''; +const REDIRECT_URI = + import.meta.env.VITE_LOGTO_REDIRECT_URI || window.location.origin + '/callback'; +const POST_LOGOUT_REDIRECT_URI = + import.meta.env.VITE_LOGTO_POST_LOGOUT_REDIRECT_URI || window.location.origin; const TOKEN_KEY = 'logto_token'; const ID_TOKEN_KEY = 'logto_id_token'; @@ -27,7 +29,7 @@ export interface LogtoTokens { } // === Storage helpers === -function saveTokens(t: LogtoTokens) { +function saveTokens(t: LogtoTokens): void { localStorage.setItem(TOKEN_KEY, t.accessToken); localStorage.setItem(ID_TOKEN_KEY, t.idToken); if (t.refreshToken) localStorage.setItem(REFRESH_KEY, t.refreshToken); @@ -43,7 +45,7 @@ function loadTokens(): LogtoTokens | null { return { accessToken, idToken, refreshToken, expiresAt }; } -function clearTokens() { +function clearTokens(): void { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(ID_TOKEN_KEY); localStorage.removeItem(REFRESH_KEY); @@ -51,27 +53,60 @@ function clearTokens() { localStorage.removeItem(USER_KEY); } -// === Auth flow (simplificado - sem PKCE) === -export function signIn(): void { - const state = Math.random().toString(36).substring(2); - const nonce = Math.random().toString(36).substring(2); +// === PKCE helpers === +function generateRandomString(len: number): string { + const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + const arr = new Uint8Array(len); + crypto.getRandomValues(arr); + let out = ''; + for (let i = 0; i < len; i++) out += charset[arr[i] % charset.length]; + return out; +} + +async function sha256(input: string): Promise { + const data = new TextEncoder().encode(input); + return await crypto.subtle.digest('SHA-256', data); +} + +function base64UrlEncode(buf: ArrayBuffer): string { + const bytes = new Uint8Array(buf); + let str = ''; + for (let i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i]); + const b64 = btoa(str); + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +async function generatePkce(): Promise<{ verifier: string; challenge: string }> { + const verifier = generateRandomString(64); + const challenge = base64UrlEncode(await sha256(verifier)); + return { verifier, challenge }; +} + +// === Auth flow === +export async function signIn(): Promise { + const state = generateRandomString(32); + const nonce = generateRandomString(32); + const pkce = await generatePkce(); try { sessionStorage.setItem('logto_state', state); sessionStorage.setItem('logto_nonce', nonce); + sessionStorage.setItem('logto_verifier', pkce.verifier); } catch { - // ignore + /* ignore */ } - const params = new URLSearchParams(); - params.set('client_id', APP_ID || ''); - params.set('redirect_uri', REDIRECT_URI); - params.set('response_type', 'code'); - params.set('scope', 'openid profile email'); - params.set('state', state); - params.set('nonce', nonce); + const params = new URLSearchParams({ + client_id: APP_ID, + redirect_uri: REDIRECT_URI, + response_type: 'code', + scope: 'openid profile email', + state, + nonce, + code_challenge: pkce.challenge, + code_challenge_method: 'S256', + }); - // Redireciona pra Logto window.location.assign(LOGTO_ENDPOINT + '/oidc/auth?' + params.toString()); } @@ -89,15 +124,20 @@ export async function handleCallback(): Promise { return false; } + const verifier = sessionStorage.getItem('logto_verifier') || ''; + + const body = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: APP_ID, + code, + redirect_uri: REDIRECT_URI, + code_verifier: verifier, + }); + const res = await fetch(LOGTO_ENDPOINT + '/oidc/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'authorization_code', - client_id: APP_ID || '', - code, - redirect_uri: REDIRECT_URI, - }), + body: body.toString(), }); if (!res.ok) { @@ -131,7 +171,7 @@ export async function getUser(): Promise { const cached = localStorage.getItem(USER_KEY); if (cached) { try { - return JSON.parse(cached); + return JSON.parse(cached) as LogtoUser; } catch { /* ignore */ } @@ -150,7 +190,7 @@ export async function getUser(): Promise { return null; } - const user = await res.json(); + const user = (await res.json()) as LogtoUser; localStorage.setItem(USER_KEY, JSON.stringify(user)); return user; } catch { @@ -163,14 +203,16 @@ async function refreshAccessToken(): Promise { if (!tokens?.refreshToken) return false; try { + const body = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: APP_ID, + refresh_token: tokens.refreshToken, + }); + const res = await fetch(LOGTO_ENDPOINT + '/oidc/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'refresh_token', - client_id: APP_ID || '', - refresh_token: tokens.refreshToken, - }), + body: body.toString(), }); if (!res.ok) return false; @@ -193,13 +235,14 @@ export async function signOut(): Promise { try { sessionStorage.removeItem('logto_state'); sessionStorage.removeItem('logto_nonce'); + sessionStorage.removeItem('logto_verifier'); } catch { - // ignore + /* ignore */ } window.location.assign( LOGTO_ENDPOINT + '/oidc/session/end?client_id=' + - (APP_ID || '') + + encodeURIComponent(APP_ID) + '&post_logout_redirect_uri=' + encodeURIComponent(POST_LOGOUT_REDIRECT_URI) ); diff --git a/src/pages/Callback.tsx b/src/pages/Callback.tsx new file mode 100644 index 0000000..aed2395 --- /dev/null +++ b/src/pages/Callback.tsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { BeamsBackground } from '@/components/ui/beams-background'; +import { useAuth } from '@/hooks/useAuth'; + +const Callback = () => { + const navigate = useNavigate(); + const { handleCallback, user, loading } = useAuth(); + const [status, setStatus] = useState<'processing' | 'success' | 'error'>('processing'); + const [message, setMessage] = useState('Processando login...'); + + useEffect(() => { + let cancelled = false; + + async function processCallback() { + try { + const ok = await handleCallback(); + if (cancelled) return; + + if (ok) { + setStatus('success'); + setMessage('Login realizado com sucesso!'); + setTimeout(() => navigate('/'), 1000); + } else { + setStatus('error'); + setMessage('Falha no login. Tente novamente.'); + setTimeout(() => navigate('/auth'), 2500); + } + } catch (err) { + if (cancelled) return; + setStatus('error'); + setMessage('Erro inesperado: ' + (err as Error).message); + setTimeout(() => navigate('/auth'), 3000); + } + } + + processCallback(); + + return () => { + cancelled = true; + }; + }, [handleCallback, navigate]); + + // Se já tá logado, redireciona + useEffect(() => { + if (!loading && user) { + navigate('/'); + } + }, [user, loading, navigate]); + + return ( + +
+
+ {status === 'processing' && ( + <> +
+

{message}

+ + )} + {status === 'success' && ( + <> +
+ + + +
+

{message}

+ + )} + {status === 'error' && ( + <> +
+ + + +
+

{message}

+ + )} +
+
+ + ); +}; + +export default Callback; \ No newline at end of file