diff --git a/src/shared/keycloak-auth.ts b/src/shared/keycloak-auth.ts index 0560221..54f452b 100644 --- a/src/shared/keycloak-auth.ts +++ b/src/shared/keycloak-auth.ts @@ -50,13 +50,27 @@ export async function verifyAccessToken(token: string): Promise { return payload; } +/** + * 运行时数组守卫:非数组(例如 Keycloak 把 realm_access.roles 误配成单值 String claim) + * 一律降为空数组——失败关闭。否则 `roles.includes('admin')` 会退化成 + * String.prototype.includes,"superadmin" 将子串命中 "admin" 造成越权。 + */ +function stringArray(v: unknown): string[] { + return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; +} + export function toAuthUser(p: JWTPayload): AuthUser { const username = (p['preferred_username'] as string | undefined) ?? ''; + const realmAccess = p['realm_access']; const user: AuthUser = { id: p.sub ?? '', username, - roles: (p['realm_access'] as { roles?: string[] } | undefined)?.roles ?? [], - groups: (p['groups'] as string[] | undefined) ?? [], + roles: stringArray( + typeof realmAccess === 'object' && realmAccess !== null + ? (realmAccess as { roles?: unknown }).roles + : undefined, + ), + groups: stringArray(p['groups']), isService: username.startsWith('service-account-'), }; const azp = p['azp'];