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:
2026-08-18 10:23:29 +00:00
parent a3c76c17b4
commit 0ce317a8f3
4 changed files with 195 additions and 34 deletions
+77 -34
View File
@@ -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<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 {
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<boolean> {
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<LogtoUser | null> {
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<LogtoUser | null> {
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<boolean> {
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<void> {
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)
);