import Keycloak from "keycloak-js"; import { reactive } from "vue"; export const auth = reactive({ ready: false, enabled: false, authenticated: false, username: "", email: "", groups: [], loginUrl: "", resetUrl: "", accountUrl: "", accountPasswordUrl: "", token: "", }); let keycloak = null; let initPromise = null; /** * Build a Keycloak client for the current environment. * * WHY: tests need to inject a predictable client without changing the runtime * behavior for the browser. */ export function createKeycloak(config) { const factory = globalThis.__ATLAS_KEYCLOAK_FACTORY__; if (typeof factory === "function") return factory(config); const ctor = globalThis.__ATLAS_KEYCLOAK_CONSTRUCTOR__; if (typeof ctor === "function") return new ctor(config); return new Keycloak(config); } /** * Normalize Keycloak groups into the format the UI expects. * * @param {unknown} groups - Raw group list from the access token. * @returns {string[]} A cleaned list of group names without leading slashes. */ export function normalizeGroups(groups) { if (!Array.isArray(groups)) return []; return groups .filter((g) => typeof g === "string") .map((g) => g.replace(/^\//, "")) .filter(Boolean); } /** * Refresh the reactive auth state from the current Keycloak token. * * WHY: the UI reads from a shared reactive object, so a token refresh needs to * update all dependent fields in one place. */ function updateFromToken() { const parsed = keycloak?.tokenParsed || {}; auth.authenticated = Boolean(keycloak?.authenticated); auth.token = keycloak?.token || ""; auth.username = parsed.preferred_username || ""; auth.email = parsed.email || ""; auth.groups = normalizeGroups(parsed.groups); } /** * Initialize Keycloak session probing and populate the reactive auth state. * * @returns {Promise} A singleton promise so callers can await startup. */ export async function initAuth() { if (initPromise) return initPromise; initPromise = (async () => { try { const resp = await fetch("/api/auth/config", { headers: { Accept: "application/json" } }); if (!resp.ok) throw new Error(`auth config ${resp.status}`); const cfg = await resp.json(); auth.enabled = Boolean(cfg.enabled); auth.loginUrl = cfg.login_url || ""; auth.resetUrl = cfg.reset_url || ""; auth.accountUrl = cfg.account_url || ""; auth.accountPasswordUrl = cfg.account_password_url || ""; if (!auth.enabled) return; keycloak = createKeycloak({ url: cfg.url, realm: cfg.realm, clientId: cfg.client_id, }); const authenticated = await keycloak.init({ onLoad: "check-sso", pkceMethod: "S256", silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`, checkLoginIframe: true, scope: "openid profile email", }); auth.authenticated = authenticated; updateFromToken(); keycloak.onAuthSuccess = () => updateFromToken(); keycloak.onAuthLogout = () => updateFromToken(); keycloak.onAuthRefreshSuccess = () => updateFromToken(); keycloak.onTokenExpired = () => { keycloak .updateToken(30) .then(() => updateFromToken()) .catch(() => updateFromToken()); }; window.setInterval(() => { if (!keycloak?.authenticated) return; keycloak.updateToken(60).then(updateFromToken).catch(() => {}); }, 30_000); } catch { auth.enabled = false; } finally { auth.ready = true; } })(); return initPromise; } /** * Open the Keycloak login flow and preserve the current location as the return * target. */ export async function login( redirectPath = window.location.pathname + window.location.search + window.location.hash, loginHint = "", ) { if (!keycloak) return; const redirectUri = new URL(redirectPath, window.location.origin).toString(); const options = { redirectUri }; if (typeof loginHint === "string" && loginHint.trim()) { options.loginHint = loginHint.trim(); } await keycloak.login(options); } /** * Log the current user out of Keycloak and return them to the portal root. */ export async function logout() { if (!keycloak) return; await keycloak.logout({ redirectUri: window.location.origin }); } /** * Perform a fetch with the current bearer token attached when available. * * @param {string} url - Target URL. * @param {RequestInit} options - Standard fetch options. * @returns {Promise} The browser fetch response. */ export async function authFetch(url, options = {}) { const headers = new Headers(options.headers || {}); if (keycloak?.authenticated) { try { await keycloak.updateToken(30); updateFromToken(); } catch { // ignore refresh failures; the API will return 401 and the UI can prompt for login } } if (auth.token) headers.set("Authorization", `Bearer ${auth.token}`); return fetch(url, { ...options, headers }); }