171 lines
4.9 KiB
JavaScript
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 });
|
|
}
|