171 lines
4.9 KiB
JavaScript

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<void>} 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<Response>} 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 });
}