fix(logto): adicionar PKCE + rota /callback
- 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
This commit is contained in:
@@ -24,6 +24,11 @@ export const routeConfig: RouteDefinition[] = [
|
|||||||
component: () => import('@/pages/Auth'),
|
component: () => import('@/pages/Auth'),
|
||||||
guard: 'public',
|
guard: 'public',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/callback',
|
||||||
|
component: () => import('@/pages/Callback'),
|
||||||
|
guard: 'public',
|
||||||
|
},
|
||||||
|
|
||||||
// ── Dashboard ────────────────────────────────────────────
|
// ── Dashboard ────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface UseAuthReturn {
|
|||||||
signUp: (email: string, password: string) => Promise<{ error: unknown }>;
|
signUp: (email: string, password: string) => Promise<{ error: unknown }>;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
updatePassword: (password: string) => Promise<{ error: unknown }>;
|
updatePassword: (password: string) => Promise<{ error: unknown }>;
|
||||||
|
handleCallback: () => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<UseAuthReturn | undefined>(undefined);
|
const AuthContext = createContext<UseAuthReturn | undefined>(undefined);
|
||||||
@@ -92,6 +93,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleCallbackFn = useCallback(async (): Promise<boolean> => {
|
||||||
|
return await handleCallback();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const value: UseAuthReturn = {
|
const value: UseAuthReturn = {
|
||||||
user,
|
user,
|
||||||
loading,
|
loading,
|
||||||
@@ -101,6 +106,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
signUp,
|
signUp,
|
||||||
signOut,
|
signOut,
|
||||||
updatePassword,
|
updatePassword,
|
||||||
|
handleCallback: handleCallbackFn,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
|||||||
+77
-34
@@ -1,10 +1,12 @@
|
|||||||
// Client Logto puro (sem @logto/react, sem instalar pacotes)
|
// 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 LOGTO_ENDPOINT = import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001';
|
||||||
const APP_ID = import.meta.env.VITE_LOGTO_APP_ID;
|
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 REDIRECT_URI =
|
||||||
const POST_LOGOUT_REDIRECT_URI = import.meta.env.VITE_LOGTO_POST_LOGOUT_REDIRECT_URI || window.location.origin;
|
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 TOKEN_KEY = 'logto_token';
|
||||||
const ID_TOKEN_KEY = 'logto_id_token';
|
const ID_TOKEN_KEY = 'logto_id_token';
|
||||||
@@ -27,7 +29,7 @@ export interface LogtoTokens {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// === Storage helpers ===
|
// === Storage helpers ===
|
||||||
function saveTokens(t: LogtoTokens) {
|
function saveTokens(t: LogtoTokens): void {
|
||||||
localStorage.setItem(TOKEN_KEY, t.accessToken);
|
localStorage.setItem(TOKEN_KEY, t.accessToken);
|
||||||
localStorage.setItem(ID_TOKEN_KEY, t.idToken);
|
localStorage.setItem(ID_TOKEN_KEY, t.idToken);
|
||||||
if (t.refreshToken) localStorage.setItem(REFRESH_KEY, t.refreshToken);
|
if (t.refreshToken) localStorage.setItem(REFRESH_KEY, t.refreshToken);
|
||||||
@@ -43,7 +45,7 @@ function loadTokens(): LogtoTokens | null {
|
|||||||
return { accessToken, idToken, refreshToken, expiresAt };
|
return { accessToken, idToken, refreshToken, expiresAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearTokens() {
|
function clearTokens(): void {
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
localStorage.removeItem(ID_TOKEN_KEY);
|
localStorage.removeItem(ID_TOKEN_KEY);
|
||||||
localStorage.removeItem(REFRESH_KEY);
|
localStorage.removeItem(REFRESH_KEY);
|
||||||
@@ -51,27 +53,60 @@ function clearTokens() {
|
|||||||
localStorage.removeItem(USER_KEY);
|
localStorage.removeItem(USER_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Auth flow (simplificado - sem PKCE) ===
|
// === PKCE helpers ===
|
||||||
export function signIn(): void {
|
function generateRandomString(len: number): string {
|
||||||
const state = Math.random().toString(36).substring(2);
|
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||||
const nonce = Math.random().toString(36).substring(2);
|
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<ArrayBuffer> {
|
||||||
|
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<void> {
|
||||||
|
const state = generateRandomString(32);
|
||||||
|
const nonce = generateRandomString(32);
|
||||||
|
const pkce = await generatePkce();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem('logto_state', state);
|
sessionStorage.setItem('logto_state', state);
|
||||||
sessionStorage.setItem('logto_nonce', nonce);
|
sessionStorage.setItem('logto_nonce', nonce);
|
||||||
|
sessionStorage.setItem('logto_verifier', pkce.verifier);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams({
|
||||||
params.set('client_id', APP_ID || '');
|
client_id: APP_ID,
|
||||||
params.set('redirect_uri', REDIRECT_URI);
|
redirect_uri: REDIRECT_URI,
|
||||||
params.set('response_type', 'code');
|
response_type: 'code',
|
||||||
params.set('scope', 'openid profile email');
|
scope: 'openid profile email',
|
||||||
params.set('state', state);
|
state,
|
||||||
params.set('nonce', nonce);
|
nonce,
|
||||||
|
code_challenge: pkce.challenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
});
|
||||||
|
|
||||||
// Redireciona pra Logto
|
|
||||||
window.location.assign(LOGTO_ENDPOINT + '/oidc/auth?' + params.toString());
|
window.location.assign(LOGTO_ENDPOINT + '/oidc/auth?' + params.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,15 +124,20 @@ export async function handleCallback(): Promise<boolean> {
|
|||||||
return false;
|
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', {
|
const res = await fetch(LOGTO_ENDPOINT + '/oidc/token', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: new URLSearchParams({
|
body: body.toString(),
|
||||||
grant_type: 'authorization_code',
|
|
||||||
client_id: APP_ID || '',
|
|
||||||
code,
|
|
||||||
redirect_uri: REDIRECT_URI,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -131,7 +171,7 @@ export async function getUser(): Promise<LogtoUser | null> {
|
|||||||
const cached = localStorage.getItem(USER_KEY);
|
const cached = localStorage.getItem(USER_KEY);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(cached);
|
return JSON.parse(cached) as LogtoUser;
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
@@ -150,7 +190,7 @@ export async function getUser(): Promise<LogtoUser | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await res.json();
|
const user = (await res.json()) as LogtoUser;
|
||||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||||
return user;
|
return user;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -163,14 +203,16 @@ async function refreshAccessToken(): Promise<boolean> {
|
|||||||
if (!tokens?.refreshToken) return false;
|
if (!tokens?.refreshToken) return false;
|
||||||
|
|
||||||
try {
|
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', {
|
const res = await fetch(LOGTO_ENDPOINT + '/oidc/token', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: new URLSearchParams({
|
body: body.toString(),
|
||||||
grant_type: 'refresh_token',
|
|
||||||
client_id: APP_ID || '',
|
|
||||||
refresh_token: tokens.refreshToken,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) return false;
|
if (!res.ok) return false;
|
||||||
@@ -193,13 +235,14 @@ export async function signOut(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
sessionStorage.removeItem('logto_state');
|
sessionStorage.removeItem('logto_state');
|
||||||
sessionStorage.removeItem('logto_nonce');
|
sessionStorage.removeItem('logto_nonce');
|
||||||
|
sessionStorage.removeItem('logto_verifier');
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
/* ignore */
|
||||||
}
|
}
|
||||||
window.location.assign(
|
window.location.assign(
|
||||||
LOGTO_ENDPOINT +
|
LOGTO_ENDPOINT +
|
||||||
'/oidc/session/end?client_id=' +
|
'/oidc/session/end?client_id=' +
|
||||||
(APP_ID || '') +
|
encodeURIComponent(APP_ID) +
|
||||||
'&post_logout_redirect_uri=' +
|
'&post_logout_redirect_uri=' +
|
||||||
encodeURIComponent(POST_LOGOUT_REDIRECT_URI)
|
encodeURIComponent(POST_LOGOUT_REDIRECT_URI)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<BeamsBackground intensity="medium">
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4">
|
||||||
|
<div className="text-center bg-background/80 backdrop-blur-sm rounded-2xl shadow-xl p-8 max-w-md">
|
||||||
|
{status === 'processing' && (
|
||||||
|
<>
|
||||||
|
<div className="h-12 w-12 mx-auto mb-4 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||||
|
<p className="text-lg font-medium text-foreground">{message}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === 'success' && (
|
||||||
|
<>
|
||||||
|
<div className="mx-auto mb-4 w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-green-600"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-medium text-foreground">{message}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === 'error' && (
|
||||||
|
<>
|
||||||
|
<div className="mx-auto mb-4 w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-red-600"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-medium text-foreground">{message}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BeamsBackground>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Callback;
|
||||||
Reference in New Issue
Block a user