This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/web/src/shared/auth.ts
T

85 lines
3.2 KiB
TypeScript

/**
* 统一鉴权中心(Keycloak)的前端落点(ADR-012;手册 `docs/keycloak-frontend-guide` §4)。
*
* 后端是**资源服务器**:不做登录、没有会话 cookie,每个请求带
* `Authorization: Bearer <access_token>`。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<AuthState> {
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<void> {
return keycloak.login();
}
/** 必须用这个登出:只清本地状态的话 Keycloak 的 SSO 会话还在,下次会静默登回来 */
export function logout(): Promise<void> {
return keycloak.logout({ redirectUri: window.location.origin });
}
/**
* 请求前调用,拿回一个保证没过期的 token(剩余有效期不足 30 秒才真刷新)。
* 未到阈值时 `updateToken` 不发网络请求,每个请求都调是安全的。
* 未登录 / 未初始化(测试)时回 `undefined`,请求就不带头 —— 让后端照实回 401。
*/
export async function freshToken(): Promise<string | undefined> {
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<boolean> {
if (!ready || !keycloak.authenticated) return false;
try {
await keycloak.updateToken(Number.MAX_SAFE_INTEGER);
return keycloak.token !== undefined;
} catch {
return false;
}
}