// ============ AcademIA8 · Consola — autenticación Cognito Hosted UI + PKCE ============ // App client público (sin secret) → OAuth authorization_code + PKCE. // Flujo: sin token → redirect a la Hosted UI con code_challenge; el callback // (?code=) se intercambia por tokens en el token endpoint con el code_verifier; // se guardan los tokens; el ID token da name/email; logout redirige al endpoint // de logout de Cognito. // // redirect_uri/logout_uri se calculan de `window.location.origin` en vez de // leer `cfg.redirectUri`/`cfg.logoutUri` (valor fijo de config.json, siempre // el dominio de CloudFront) -- así el login se queda en el mismo dominio por // el que entró el usuario (CloudFront o admin.academia8.com), en vez de // autenticar y rebotar siempre al de CloudFront. Requiere que el dominio esté // registrado como callback/logout URL en el UserPoolClient (infra/stacks/ // academia8_stack.py, `console_callback_urls`/`console_logout_urls`) -- si no // lo está, Cognito rechaza el redirect con `redirect_mismatch`, por diseño. const A8Auth = (() => { const CFG = () => window.A8_CONFIG || {}; const LS_TOKENS = "a8_tokens"; const SS_VERIFIER = "a8_pkce_verifier"; const originUri = () => window.location.origin + "/"; // ---- PKCE helpers ---- function b64url(bytes) { let s = btoa(String.fromCharCode(...new Uint8Array(bytes))); return s.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } function randomVerifier() { const a = new Uint8Array(64); crypto.getRandomValues(a); return b64url(a.buffer); } async function challenge(verifier) { const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); return b64url(digest); } // ---- token storage ---- function saveTokens(t) { t.obtained_at = Math.floor(Date.now() / 1000); localStorage.setItem(LS_TOKENS, JSON.stringify(t)); } function getTokens() { try { return JSON.parse(localStorage.getItem(LS_TOKENS)); } catch (e) { return null; } } function clearTokens() { localStorage.removeItem(LS_TOKENS); } function isExpired(t) { if (!t || !t.expires_in) return true; const now = Math.floor(Date.now() / 1000); return now >= (t.obtained_at + t.expires_in - 30); // margen de 30s } function decodeJwt(jwt) { try { const payload = jwt.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"); return JSON.parse(decodeURIComponent(escape(atob(payload)))); } catch (e) { return {}; } } // ---- redirect a la Hosted UI ---- async function login() { const cfg = CFG(); const verifier = randomVerifier(); sessionStorage.setItem(SS_VERIFIER, verifier); const url = new URL(cfg.cognitoDomain + "/oauth2/authorize"); url.searchParams.set("response_type", "code"); url.searchParams.set("client_id", cfg.clientId); url.searchParams.set("redirect_uri", originUri()); url.searchParams.set("scope", cfg.scopes || "openid profile"); url.searchParams.set("code_challenge_method", "S256"); url.searchParams.set("code_challenge", await challenge(verifier)); window.location.assign(url.toString()); } // ---- intercambio del code por tokens ---- async function exchangeCode(code) { const cfg = CFG(); const verifier = sessionStorage.getItem(SS_VERIFIER) || ""; const body = new URLSearchParams({ grant_type: "authorization_code", client_id: cfg.clientId, code, redirect_uri: originUri(), code_verifier: verifier, }); const resp = await fetch(cfg.cognitoDomain + "/oauth2/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString(), }); if (!resp.ok) throw new Error("token exchange failed: " + resp.status); const tokens = await resp.json(); saveTokens(tokens); sessionStorage.removeItem(SS_VERIFIER); } // ---- API pública ---- // Garantiza sesión. Si volvemos del callback con ?code=, lo intercambia y limpia // la URL. Si no hay token válido, redirige a login (no resuelve). Devuelve true // solo si hay sesión utilizable. async function ensureAuthenticated() { const params = new URLSearchParams(window.location.search); const code = params.get("code"); if (code) { await exchangeCode(code); // limpiar el ?code= de la URL window.history.replaceState({}, document.title, window.location.pathname); return true; } const t = getTokens(); if (t && !isExpired(t)) return true; clearTokens(); await login(); return false; // redirigiendo } function authHeader() { const t = getTokens(); // El authorizer de API Gateway valida contra el app client (audience) → ID token. return t ? { Authorization: "Bearer " + (t.id_token || t.access_token) } : {}; } function user() { const t = getTokens(); if (!t) return { name: "", email: "", initials: "" }; const claims = decodeJwt(t.id_token || ""); const name = claims.name || claims.email || "Autor"; const initials = name.split(/\s+/).map((w) => w[0]).slice(0, 2).join("").toUpperCase(); return { name, email: claims.email || "", initials: initials || "A8" }; } function logout() { const cfg = CFG(); clearTokens(); const url = new URL(cfg.cognitoDomain + "/logout"); url.searchParams.set("client_id", cfg.clientId); url.searchParams.set("logout_uri", originUri()); window.location.assign(url.toString()); } return { ensureAuthenticated, authHeader, user, logout }; })(); window.A8Auth = A8Auth;