Archived
org adapter:Keycloak Admin API 拉名册(provisioning 服务账号,分页 100,只读 view-users)
用 master realm 的 provisioning 服务账号(client_credentials)取管理 token(闭包缓存、 提前 60 秒过期),分页拉全 realm 用户(默认 max=100,必须分页),逐人查 role-mappings/realm/composite 取实际生效的 realm 角色并滤噪声(复用 isNoiseRole)。 非 2xx 一律抛带状态码的错,不吞;secret 不落任何留痕。
This commit is contained in:
@@ -20,3 +20,5 @@ export { buildInFlightWork } from './internal/domain/in-flight.ts';
|
|||||||
export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, TEST_POSITION_CODE } from './internal/domain/seed-data.ts';
|
export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, TEST_POSITION_CODE } from './internal/domain/seed-data.ts';
|
||||||
export { createKeycloakTokenVerifier, explainJoseError } from './internal/adapter/keycloak-token-verifier.ts';
|
export { createKeycloakTokenVerifier, explainJoseError } from './internal/adapter/keycloak-token-verifier.ts';
|
||||||
export type { KeycloakVerifierOptions } from './internal/adapter/keycloak-token-verifier.ts';
|
export type { KeycloakVerifierOptions } from './internal/adapter/keycloak-token-verifier.ts';
|
||||||
|
export { createKeycloakRoster } from './internal/adapter/keycloak-admin-roster.ts';
|
||||||
|
export type { KeycloakRosterOptions } from './internal/adapter/keycloak-admin-roster.ts';
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/** Admin API 是外界 —— fetch 是注入的,这里给一个会分页、会发 token 的假 Keycloak */
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { createKeycloakRoster } from '../internal/adapter/keycloak-admin-roster.ts';
|
||||||
|
|
||||||
|
function fakeKeycloak(): { fetch: typeof fetch; calls: string[] } {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const users = Array.from({ length: 105 }, (_, i) => ({
|
||||||
|
id: `sub-${i}`,
|
||||||
|
username: `u${i}`,
|
||||||
|
enabled: i !== 3,
|
||||||
|
firstName: i === 0 ? '' : '名',
|
||||||
|
lastName: i === 0 ? '' : `${i}`,
|
||||||
|
email: `u${i}@x.test`,
|
||||||
|
}));
|
||||||
|
const json = (body: unknown, status = 200): Response =>
|
||||||
|
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||||
|
|
||||||
|
const fetchImpl: typeof fetch = async (input, init) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
calls.push(`${init?.method ?? 'GET'} ${url}`);
|
||||||
|
if (url.endsWith('/realms/master/protocol/openid-connect/token')) {
|
||||||
|
return json({ access_token: 'admin-token', expires_in: 300 });
|
||||||
|
}
|
||||||
|
const u = new URL(url);
|
||||||
|
if (u.pathname === '/admin/realms/corp/users') {
|
||||||
|
const first = Number(u.searchParams.get('first'));
|
||||||
|
const max = Number(u.searchParams.get('max'));
|
||||||
|
return json(users.slice(first, first + max));
|
||||||
|
}
|
||||||
|
const m = /^\/admin\/realms\/corp\/users\/([^/]+)\/role-mappings\/realm\/composite$/.exec(u.pathname);
|
||||||
|
if (m !== null) {
|
||||||
|
return json([
|
||||||
|
{ name: 'user' },
|
||||||
|
{ name: 'default-roles-corp' },
|
||||||
|
{ name: 'offline_access' },
|
||||||
|
...(m[1] === 'sub-1' ? [{ name: 'admin' }] : []),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return json({ error: 'not found' }, 404);
|
||||||
|
};
|
||||||
|
return { fetch: fetchImpl, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Keycloak Admin API 名册', () => {
|
||||||
|
it('分页拉全(100 + 5)、姓名拼 firstName lastName、缺名回退用户名、角色滤噪声、enabled 照抄', async () => {
|
||||||
|
const kc = fakeKeycloak();
|
||||||
|
const roster = createKeycloakRoster({ url: 'https://kc.test/', realm: 'corp', clientId: 'provisioning', secret: 's', fetch: kc.fetch });
|
||||||
|
const users = await roster.listUsers();
|
||||||
|
expect(users).toHaveLength(105);
|
||||||
|
expect(users[0]).toEqual({ sub: 'sub-0', account: 'u0', name: 'u0', email: 'u0@x.test', roles: ['user'], enabled: true });
|
||||||
|
expect(users[1]).toEqual({ sub: 'sub-1', account: 'u1', name: '名 1', email: 'u1@x.test', roles: ['admin', 'user'], enabled: true });
|
||||||
|
expect(users[3]?.enabled).toBe(false);
|
||||||
|
expect(kc.calls.filter((c) => c.includes('/users?')).length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('管理 token 只取一次(闭包缓存,提前 60 秒过期)', async () => {
|
||||||
|
const kc = fakeKeycloak();
|
||||||
|
let t = 0;
|
||||||
|
const roster = createKeycloakRoster({ url: 'https://kc.test', realm: 'corp', clientId: 'provisioning', secret: 's', fetch: kc.fetch, now: () => t });
|
||||||
|
await roster.listUsers();
|
||||||
|
expect(kc.calls.filter((c) => c.includes('/openid-connect/token')).length).toBe(1);
|
||||||
|
t = 241 * 1000; // expires_in 300 - 60 = 240 秒后才过期
|
||||||
|
await roster.listUsers();
|
||||||
|
expect(kc.calls.filter((c) => c.includes('/openid-connect/token')).length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Admin API 非 2xx → 抛带状态码的错(不吞)', async () => {
|
||||||
|
const fetchImpl: typeof fetch = async (input) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
if (url.endsWith('/token')) return new Response(JSON.stringify({ access_token: 't', expires_in: 300 }), { status: 200 });
|
||||||
|
return new Response('forbidden', { status: 403 });
|
||||||
|
};
|
||||||
|
const roster = createKeycloakRoster({ url: 'https://kc.test', realm: 'corp', clientId: 'p', secret: 's', fetch: fetchImpl });
|
||||||
|
await expect(roster.listUsers()).rejects.toThrow(/403/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -63,3 +63,6 @@ export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, TEST_POSITION_CODE } from './i
|
|||||||
|
|
||||||
export { createKeycloakTokenVerifier, explainJoseError } from './internal/adapter/keycloak-token-verifier.ts';
|
export { createKeycloakTokenVerifier, explainJoseError } from './internal/adapter/keycloak-token-verifier.ts';
|
||||||
export type { KeycloakVerifierOptions } from './internal/adapter/keycloak-token-verifier.ts';
|
export type { KeycloakVerifierOptions } from './internal/adapter/keycloak-token-verifier.ts';
|
||||||
|
|
||||||
|
export { createKeycloakRoster } from './internal/adapter/keycloak-admin-roster.ts';
|
||||||
|
export type { KeycloakRosterOptions } from './internal/adapter/keycloak-admin-roster.ts';
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* 名册来源:Keycloak Admin API(手册 §11 / §14;ADR-012)。
|
||||||
|
*
|
||||||
|
* 用 master realm 的 provisioning 服务账号(client_credentials)取管理 token,只读:
|
||||||
|
* GET /admin/realms/{realm}/users?first&max=100(列表默认 max=100,必须分页)
|
||||||
|
* GET /users/{id}/role-mappings/realm/composite(实际生效的 realm 角色)
|
||||||
|
* 需要的 realm-management 角色只有 `view-users`(手册 14.6 最小权限)。
|
||||||
|
*/
|
||||||
|
import type { RosterSource, RosterUser } from '../../port.ts';
|
||||||
|
import { isNoiseRole } from '../domain/claims.ts';
|
||||||
|
|
||||||
|
export type KeycloakRosterOptions = {
|
||||||
|
readonly url: string;
|
||||||
|
readonly realm: string;
|
||||||
|
readonly clientId: string;
|
||||||
|
readonly secret: string;
|
||||||
|
/** 测试注入;缺省全局 fetch */
|
||||||
|
readonly fetch?: typeof fetch;
|
||||||
|
/** 毫秒时刻;测试注入 */
|
||||||
|
readonly now?: () => number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type KcUser = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly username: string;
|
||||||
|
readonly enabled: boolean;
|
||||||
|
readonly firstName?: string;
|
||||||
|
readonly lastName?: string;
|
||||||
|
readonly email?: string;
|
||||||
|
};
|
||||||
|
type KcRole = { readonly name: string };
|
||||||
|
|
||||||
|
const PAGE = 100;
|
||||||
|
|
||||||
|
export function createKeycloakRoster(options: KeycloakRosterOptions): RosterSource {
|
||||||
|
const fetchImpl = options.fetch ?? fetch;
|
||||||
|
const now = options.now ?? ((): number => Date.now());
|
||||||
|
const base = options.url.replace(/\/+$/, '');
|
||||||
|
let cached: { token: string; expiresAt: number } | undefined;
|
||||||
|
|
||||||
|
async function adminToken(): Promise<string> {
|
||||||
|
if (cached !== undefined && now() < cached.expiresAt) return cached.token;
|
||||||
|
const resp = await fetchImpl(`${base}/realms/master/protocol/openid-connect/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
client_id: options.clientId,
|
||||||
|
client_secret: options.secret,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`取 Keycloak 管理 token 失败:${resp.status} ${await resp.text()}`);
|
||||||
|
const data = (await resp.json()) as { access_token: string; expires_in: number };
|
||||||
|
// 提前 60 秒过期,免得边界上拿着一枚刚好失效的 token 去调
|
||||||
|
cached = { token: data.access_token, expiresAt: now() + (data.expires_in - 60) * 1000 };
|
||||||
|
return cached.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminGet<T>(path: string): Promise<T> {
|
||||||
|
const token = await adminToken();
|
||||||
|
const resp = await fetchImpl(`${base}/admin/realms/${options.realm}${path}`, {
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`Admin API GET ${path} 失败:${resp.status} ${await resp.text()}`);
|
||||||
|
return (await resp.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async listUsers(): Promise<readonly RosterUser[]> {
|
||||||
|
const users: KcUser[] = [];
|
||||||
|
for (let first = 0; ; first += PAGE) {
|
||||||
|
const page = await adminGet<KcUser[]>(`/users?first=${first}&max=${PAGE}&briefRepresentation=false`);
|
||||||
|
users.push(...page);
|
||||||
|
if (page.length < PAGE) break;
|
||||||
|
}
|
||||||
|
const out: RosterUser[] = [];
|
||||||
|
for (const u of users) {
|
||||||
|
const roles = await adminGet<KcRole[]>(`/users/${encodeURIComponent(u.id)}/role-mappings/realm/composite`);
|
||||||
|
const name = [u.firstName, u.lastName]
|
||||||
|
.filter((s): s is string => s !== undefined && s.trim() !== '')
|
||||||
|
.join(' ')
|
||||||
|
.trim();
|
||||||
|
out.push({
|
||||||
|
sub: u.id,
|
||||||
|
account: u.username,
|
||||||
|
name: name === '' ? u.username : name,
|
||||||
|
roles: [...new Set(roles.map((r) => r.name).filter((r) => !isNoiseRole(r)))].sort(),
|
||||||
|
enabled: u.enabled,
|
||||||
|
...(u.email === undefined || u.email === '' ? {} : { email: u.email }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user