/** * 统一鉴权中心(Keycloak)的前端落点(ADR-012;手册 `docs/keycloak-frontend-guide` §4)。 * * 后端是**资源服务器**:不做登录、没有会话 cookie,每个请求带 * `Authorization: Bearer `。token 的取 / 刷 / 登出全部在这里, * 业务代码只见 `freshToken()` 一个口子。 * * 硬性约束(手册 §16): * · token 只放内存(keycloak-js 默认),**不进 localStorage** —— 刷新靠 SSO 会话静默恢复; * · 授权 URL 与 PKCE 一律由 keycloak-js 完成,不自己拼; * · 登出必须走 `keycloak.logout()` 结束 SSO 会话,只清本地状态会「登不出去」。 */ import Keycloak from 'keycloak-js'; export const keycloak = new Keycloak({ url: import.meta.env.VITE_KEYCLOAK_URL, realm: import.meta.env.VITE_KEYCLOAK_REALM, clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID, }); const REQUIRED_ROLE = import.meta.env.VITE_REQUIRED_ROLE as string; /** init 是否已跑过 —— 测试环境(无 Keycloak)下 freshToken 据此直接空手而归 */ let ready = false; export type AuthState = { authenticated: boolean; authorized: boolean }; /** * 初始化:静默探测浏览器是否已有 Keycloak 会话(隐藏 iframe,用户无感)。 * 不发跳转 —— 未登录交回 `authenticated: false`,由壳层决定摆登录页。 */ export async function initAuth(): Promise { const authenticated = await keycloak.init({ onLoad: 'check-sso', silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`, pkceMethod: 'S256', checkLoginIframe: false, }); ready = true; if (!authenticated) return { authenticated: false, authorized: false }; // access token 只有 5 分钟:到期前用 refresh token 悄悄续,续不上就重新登录 keycloak.onTokenExpired = () => { keycloak.updateToken(30).catch(() => keycloak.login()); }; return { authenticated: true, authorized: keycloak.hasRealmRole(REQUIRED_ROLE) }; } export function login(): Promise { return keycloak.login(); } /** 必须用这个登出:只清本地状态的话 Keycloak 的 SSO 会话还在,下次会静默登回来 */ export function logout(): Promise { return keycloak.logout({ redirectUri: window.location.origin }); } /** * 请求前调用,拿回一个保证没过期的 token(剩余有效期不足 30 秒才真刷新)。 * 未到阈值时 `updateToken` 不发网络请求,每个请求都调是安全的。 * 未登录 / 未初始化(测试)时回 `undefined`,请求就不带头 —— 让后端照实回 401。 */ export async function freshToken(): Promise { if (!ready || !keycloak.authenticated) return undefined; await keycloak.updateToken(30); return keycloak.token; } /** * 401 之后的强刷(typed-fetch 用):常规 `freshToken` 在阈值内不发请求, * 而后端已经明说这一枚不行 —— 传一个极大的 minValidity 逼它真换一枚。 * 刷不出来(SSO 会话也没了)回 false,调用方照旧把 401 抛给页面。 */ export async function forceRefreshToken(): Promise { if (!ready || !keycloak.authenticated) return false; try { await keycloak.updateToken(Number.MAX_SAFE_INTEGER); return keycloak.token !== undefined; } catch { return false; } }