/** * token claims → 一个人(纯函数,零 IO;ADR-012)。 * * 只认手册 §17 列出的那几个 claim;`name` 缺了回退用户名(服务账号常常没有 name)。 * Keycloak 给每个用户自动挂的三个「家具角色」不是业务角色,这里滤掉: * `default-roles-` / `offline_access` / `uma_authorization`。 */ import type { Identity } from '../../contract.ts'; import type { VerifiedClaims } from '../../port.ts'; const NOISE_ROLES: ReadonlySet = new Set(['offline_access', 'uma_authorization']); export function isNoiseRole(role: string): boolean { return NOISE_ROLES.has(role) || role.startsWith('default-roles-'); } /** 去重、滤噪声、排序 —— 角色快照要能逐字比对(「变了没有」靠它) */ export function normalizeRoles(roles: readonly string[]): string[] { return [...new Set(roles.filter((r) => r.trim() !== '' && !isNoiseRole(r)))].sort(); } export function identityOf(claims: VerifiedClaims): Identity { const account = claims.preferred_username; /* 姓名展示姓在前(2026-08-26 用户定):有姓/名散件就按「姓 名」拼; 没有散件才用 Keycloak 的全名 `name`(它按「名 姓」拼,中文场景顺序是反的) */ const cjk = [claims.family_name, claims.given_name] .filter((s): s is string => s !== undefined && s.trim() !== '') .join(' '); const fallback = claims.name === undefined || claims.name.trim() === '' ? account : claims.name.trim(); const name = cjk !== '' ? cjk : fallback; const email = claims.email === undefined || claims.email.trim() === '' ? undefined : claims.email.trim(); return { sub: claims.sub, account, name, roles: normalizeRoles(claims.realm_access?.roles ?? []), ...(email === undefined ? {} : { email }), }; }