@baukit/auth-node 0.7.2 → 0.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/api-origin.test.ts +72 -0
- package/src/api-origin.ts +58 -0
- package/src/cache.ts +381 -0
- package/src/conformance.test.ts +139 -0
- package/src/device-flow.ts +817 -0
- package/src/errors.ts +64 -0
- package/src/index.test.ts +578 -0
- package/src/index.ts +30 -0
- package/src/keycloak-testing.test.ts +366 -0
- package/src/keycloak-testing.ts +267 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import type { Page } from '@playwright/test';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
allowKeycloakWebOrigin,
|
|
6
|
+
createKeycloakTestUser,
|
|
7
|
+
keycloakStack,
|
|
8
|
+
revokeKeycloakUserSessions,
|
|
9
|
+
signInWithKeycloak,
|
|
10
|
+
type KeycloakLoginPage,
|
|
11
|
+
} from './keycloak-testing.js';
|
|
12
|
+
|
|
13
|
+
const STACK = keycloakStack(
|
|
14
|
+
{ url: 'http://localhost:8081/', realm: 'notes', webClientId: 'notes-web' },
|
|
15
|
+
{},
|
|
16
|
+
);
|
|
17
|
+
const TOKEN_URL = 'http://localhost:8081/realms/master/protocol/openid-connect/token';
|
|
18
|
+
const ADMIN_URL = 'http://localhost:8081/admin/realms/notes';
|
|
19
|
+
|
|
20
|
+
interface RecordedRequest {
|
|
21
|
+
readonly url: string;
|
|
22
|
+
readonly method: string;
|
|
23
|
+
readonly headers: Headers;
|
|
24
|
+
readonly body: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function bodyText(body: BodyInit | null | undefined): string {
|
|
28
|
+
if (typeof body === 'string') return body;
|
|
29
|
+
return body instanceof URLSearchParams ? body.toString() : '';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function fakeKeycloak(responses: Record<string, () => Response>): {
|
|
33
|
+
readonly fetch: typeof fetch;
|
|
34
|
+
readonly requests: RecordedRequest[];
|
|
35
|
+
} {
|
|
36
|
+
const requests: RecordedRequest[] = [];
|
|
37
|
+
const fetcher: typeof fetch = (input, init) => {
|
|
38
|
+
const method = init?.method ?? 'GET';
|
|
39
|
+
const url = input instanceof Request ? input.url : input.toString();
|
|
40
|
+
requests.push({ url, method, headers: new Headers(init?.headers), body: bodyText(init?.body) });
|
|
41
|
+
const respond = responses[`${method} ${url}`];
|
|
42
|
+
if (respond === undefined) throw new Error(`unexpected ${method} ${url}`);
|
|
43
|
+
return Promise.resolve(respond());
|
|
44
|
+
};
|
|
45
|
+
return { fetch: fetcher, requests };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const adminToken = (): Response => Response.json({ access_token: 'admin-token' });
|
|
49
|
+
|
|
50
|
+
describe('keycloakStack', () => {
|
|
51
|
+
it('uses the defaults and admin/admin without variables', () => {
|
|
52
|
+
expect(STACK).toEqual({
|
|
53
|
+
url: 'http://localhost:8081',
|
|
54
|
+
realm: 'notes',
|
|
55
|
+
adminUsername: 'admin',
|
|
56
|
+
adminPassword: 'admin',
|
|
57
|
+
webClientId: 'notes-web',
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('lets E2E_KEYCLOAK_* variables override every field', () => {
|
|
62
|
+
const stack = keycloakStack(
|
|
63
|
+
{ url: 'http://localhost:8081', realm: 'notes', webClientId: 'notes-web' },
|
|
64
|
+
{
|
|
65
|
+
E2E_KEYCLOAK_URL: 'https://id.example.test//',
|
|
66
|
+
E2E_KEYCLOAK_REALM: 'staging',
|
|
67
|
+
E2E_KEYCLOAK_ADMIN_USERNAME: 'root',
|
|
68
|
+
E2E_KEYCLOAK_ADMIN_PASSWORD: 'secret',
|
|
69
|
+
E2E_KEYCLOAK_WEB_CLIENT_ID: 'staging-web',
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
expect(stack).toEqual({
|
|
73
|
+
url: 'https://id.example.test',
|
|
74
|
+
realm: 'staging',
|
|
75
|
+
adminUsername: 'root',
|
|
76
|
+
adminPassword: 'secret',
|
|
77
|
+
webClientId: 'staging-web',
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('createKeycloakTestUser', () => {
|
|
83
|
+
it('creates a verified random user and returns the subject from the location', async () => {
|
|
84
|
+
const keycloak = fakeKeycloak({
|
|
85
|
+
[`POST ${TOKEN_URL}`]: adminToken,
|
|
86
|
+
[`POST ${ADMIN_URL}/users`]: () =>
|
|
87
|
+
new Response(null, {
|
|
88
|
+
status: 201,
|
|
89
|
+
headers: { location: `${ADMIN_URL}/users/0f4c-subject` },
|
|
90
|
+
}),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const user = await createKeycloakTestUser(STACK, {}, { fetch: keycloak.fetch });
|
|
94
|
+
|
|
95
|
+
expect(user.subject).toBe('0f4c-subject');
|
|
96
|
+
expect(user.username).toMatch(/^e2e-[0-9a-f-]{36}$/u);
|
|
97
|
+
expect(user.email).toBe(`${user.username}@example.test`);
|
|
98
|
+
const [login, create] = keycloak.requests;
|
|
99
|
+
expect(Object.fromEntries(new URLSearchParams(login?.body))).toEqual({
|
|
100
|
+
client_id: 'admin-cli',
|
|
101
|
+
grant_type: 'password',
|
|
102
|
+
username: 'admin',
|
|
103
|
+
password: 'admin',
|
|
104
|
+
});
|
|
105
|
+
expect(create?.headers.get('authorization')).toBe('Bearer admin-token');
|
|
106
|
+
expect(JSON.parse(create?.body ?? '')).toMatchObject({
|
|
107
|
+
username: user.username,
|
|
108
|
+
email: user.email,
|
|
109
|
+
emailVerified: true,
|
|
110
|
+
enabled: true,
|
|
111
|
+
credentials: [{ type: 'password', value: user.password, temporary: false }],
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('keeps a caller-chosen identity', async () => {
|
|
116
|
+
const keycloak = fakeKeycloak({
|
|
117
|
+
[`POST ${TOKEN_URL}`]: adminToken,
|
|
118
|
+
[`POST ${ADMIN_URL}/users`]: () =>
|
|
119
|
+
new Response(null, { status: 201, headers: { location: `${ADMIN_URL}/users/abc` } }),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const user = await createKeycloakTestUser(
|
|
123
|
+
STACK,
|
|
124
|
+
{ username: 'ada@example.com', email: 'ada@example.com', password: 'pw' },
|
|
125
|
+
{ fetch: keycloak.fetch },
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect(user).toEqual({
|
|
129
|
+
username: 'ada@example.com',
|
|
130
|
+
email: 'ada@example.com',
|
|
131
|
+
password: 'pw',
|
|
132
|
+
subject: 'abc',
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('names the failing step and status without a response body', async () => {
|
|
137
|
+
const refused = fakeKeycloak({
|
|
138
|
+
[`POST ${TOKEN_URL}`]: () => new Response('bad credentials', { status: 401 }),
|
|
139
|
+
});
|
|
140
|
+
await expect(createKeycloakTestUser(STACK, {}, { fetch: refused.fetch })).rejects.toThrow(
|
|
141
|
+
'Keycloak admin sign-in failed with HTTP 401.',
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const conflict = fakeKeycloak({
|
|
145
|
+
[`POST ${TOKEN_URL}`]: adminToken,
|
|
146
|
+
[`POST ${ADMIN_URL}/users`]: () => new Response(null, { status: 409 }),
|
|
147
|
+
});
|
|
148
|
+
await expect(createKeycloakTestUser(STACK, {}, { fetch: conflict.fetch })).rejects.toThrow(
|
|
149
|
+
'Keycloak user creation failed with HTTP 409.',
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const noLocation = fakeKeycloak({
|
|
153
|
+
[`POST ${TOKEN_URL}`]: adminToken,
|
|
154
|
+
[`POST ${ADMIN_URL}/users`]: () => new Response(null, { status: 201 }),
|
|
155
|
+
});
|
|
156
|
+
await expect(createKeycloakTestUser(STACK, {}, { fetch: noLocation.fetch })).rejects.toThrow(
|
|
157
|
+
'Keycloak user creation returned no user location.',
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('revokeKeycloakUserSessions', () => {
|
|
163
|
+
it('logs the user out through the admin API', async () => {
|
|
164
|
+
const keycloak = fakeKeycloak({
|
|
165
|
+
[`POST ${TOKEN_URL}`]: adminToken,
|
|
166
|
+
[`POST ${ADMIN_URL}/users/abc/logout`]: () => new Response(null, { status: 204 }),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await revokeKeycloakUserSessions(STACK, 'abc', { fetch: keycloak.fetch });
|
|
170
|
+
|
|
171
|
+
expect(keycloak.requests.map((request) => request.method)).toEqual(['POST', 'POST']);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('rejects a failed revocation', async () => {
|
|
175
|
+
const keycloak = fakeKeycloak({
|
|
176
|
+
[`POST ${TOKEN_URL}`]: adminToken,
|
|
177
|
+
[`POST ${ADMIN_URL}/users/abc/logout`]: () => new Response(null, { status: 404 }),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await expect(
|
|
181
|
+
revokeKeycloakUserSessions(STACK, 'abc', { fetch: keycloak.fetch }),
|
|
182
|
+
).rejects.toThrow('Keycloak session revocation failed with HTTP 404.');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('allowKeycloakWebOrigin', () => {
|
|
187
|
+
const lookup = `GET ${ADMIN_URL}/clients?clientId=notes-web`;
|
|
188
|
+
|
|
189
|
+
it('adds a missing origin and keeps the rest of the client', async () => {
|
|
190
|
+
const keycloak = fakeKeycloak({
|
|
191
|
+
[`POST ${TOKEN_URL}`]: adminToken,
|
|
192
|
+
[lookup]: () =>
|
|
193
|
+
Response.json([
|
|
194
|
+
{
|
|
195
|
+
id: 'client-1',
|
|
196
|
+
clientId: 'notes-web',
|
|
197
|
+
redirectUris: ['http://localhost:5173/*'],
|
|
198
|
+
webOrigins: ['http://localhost:5173'],
|
|
199
|
+
},
|
|
200
|
+
]),
|
|
201
|
+
[`PUT ${ADMIN_URL}/clients/client-1`]: () => new Response(null, { status: 204 }),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
await allowKeycloakWebOrigin(STACK, 'http://localhost:5183', { fetch: keycloak.fetch });
|
|
205
|
+
|
|
206
|
+
const update = keycloak.requests.at(-1);
|
|
207
|
+
expect(update?.headers.get('content-type')).toBe('application/json');
|
|
208
|
+
expect(JSON.parse(update?.body ?? '')).toEqual({
|
|
209
|
+
id: 'client-1',
|
|
210
|
+
clientId: 'notes-web',
|
|
211
|
+
redirectUris: ['http://localhost:5173/*', 'http://localhost:5183/*'],
|
|
212
|
+
webOrigins: ['http://localhost:5173', 'http://localhost:5183'],
|
|
213
|
+
attributes: { 'post.logout.redirect.uris': 'http://localhost:5183/*' },
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('appends the post-logout redirect and keeps the other attributes', async () => {
|
|
218
|
+
const keycloak = fakeKeycloak({
|
|
219
|
+
[`POST ${TOKEN_URL}`]: adminToken,
|
|
220
|
+
[lookup]: () =>
|
|
221
|
+
Response.json([
|
|
222
|
+
{
|
|
223
|
+
id: 'client-1',
|
|
224
|
+
redirectUris: ['http://localhost:5183/*'],
|
|
225
|
+
webOrigins: ['http://localhost:5183'],
|
|
226
|
+
attributes: {
|
|
227
|
+
'pkce.code.challenge.method': 'S256',
|
|
228
|
+
'post.logout.redirect.uris': 'http://localhost:5173/*',
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
]),
|
|
232
|
+
[`PUT ${ADMIN_URL}/clients/client-1`]: () => new Response(null, { status: 204 }),
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
await allowKeycloakWebOrigin(STACK, 'http://localhost:5183', { fetch: keycloak.fetch });
|
|
236
|
+
|
|
237
|
+
expect(JSON.parse(keycloak.requests.at(-1)?.body ?? '')).toMatchObject({
|
|
238
|
+
redirectUris: ['http://localhost:5183/*'],
|
|
239
|
+
webOrigins: ['http://localhost:5183'],
|
|
240
|
+
attributes: {
|
|
241
|
+
'pkce.code.challenge.method': 'S256',
|
|
242
|
+
'post.logout.redirect.uris': 'http://localhost:5173/*##http://localhost:5183/*',
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it.each([
|
|
248
|
+
['lists the origin', 'http://localhost:5173/*'],
|
|
249
|
+
['reuses the redirect URIs', 'http://localhost:5183/*##+'],
|
|
250
|
+
])('skips the update when the client already allows the origin and %s', async (_, uris) => {
|
|
251
|
+
const keycloak = fakeKeycloak({
|
|
252
|
+
[`POST ${TOKEN_URL}`]: adminToken,
|
|
253
|
+
[lookup]: () =>
|
|
254
|
+
Response.json([
|
|
255
|
+
{
|
|
256
|
+
id: 'client-1',
|
|
257
|
+
redirectUris: ['http://localhost:5173/*'],
|
|
258
|
+
webOrigins: ['http://localhost:5173'],
|
|
259
|
+
attributes: { 'post.logout.redirect.uris': uris },
|
|
260
|
+
},
|
|
261
|
+
]),
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
await allowKeycloakWebOrigin(STACK, 'http://localhost:5173', { fetch: keycloak.fetch });
|
|
265
|
+
|
|
266
|
+
expect(keycloak.requests).toHaveLength(2);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('updates a client that allows the origin everywhere but after logout', async () => {
|
|
270
|
+
const keycloak = fakeKeycloak({
|
|
271
|
+
[`POST ${TOKEN_URL}`]: adminToken,
|
|
272
|
+
[lookup]: () =>
|
|
273
|
+
Response.json([
|
|
274
|
+
{
|
|
275
|
+
id: 'client-1',
|
|
276
|
+
redirectUris: ['http://localhost:5173/*'],
|
|
277
|
+
webOrigins: ['http://localhost:5173'],
|
|
278
|
+
},
|
|
279
|
+
]),
|
|
280
|
+
[`PUT ${ADMIN_URL}/clients/client-1`]: () => new Response(null, { status: 204 }),
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
await allowKeycloakWebOrigin(STACK, 'http://localhost:5173', { fetch: keycloak.fetch });
|
|
284
|
+
|
|
285
|
+
expect(JSON.parse(keycloak.requests.at(-1)?.body ?? '')).toEqual({
|
|
286
|
+
id: 'client-1',
|
|
287
|
+
redirectUris: ['http://localhost:5173/*'],
|
|
288
|
+
webOrigins: ['http://localhost:5173'],
|
|
289
|
+
attributes: { 'post.logout.redirect.uris': 'http://localhost:5173/*' },
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('rejects a failed client update', async () => {
|
|
294
|
+
const keycloak = fakeKeycloak({
|
|
295
|
+
[`POST ${TOKEN_URL}`]: adminToken,
|
|
296
|
+
[lookup]: () => Response.json([{ id: 'client-1' }]),
|
|
297
|
+
[`PUT ${ADMIN_URL}/clients/client-1`]: () => new Response(null, { status: 403 }),
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await expect(
|
|
301
|
+
allowKeycloakWebOrigin(STACK, 'http://localhost:5173', { fetch: keycloak.fetch }),
|
|
302
|
+
).rejects.toThrow('Keycloak client update failed with HTTP 403.');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('rejects a realm without the web client', async () => {
|
|
306
|
+
const keycloak = fakeKeycloak({
|
|
307
|
+
[`POST ${TOKEN_URL}`]: adminToken,
|
|
308
|
+
[lookup]: () => Response.json([]),
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
await expect(
|
|
312
|
+
allowKeycloakWebOrigin(STACK, 'http://localhost:5173', { fetch: keycloak.fetch }),
|
|
313
|
+
).rejects.toThrow('Keycloak realm notes has no notes-web client.');
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
describe('signInWithKeycloak', () => {
|
|
318
|
+
function fakePage(): { readonly page: KeycloakLoginPage; readonly steps: string[] } {
|
|
319
|
+
const steps: string[] = [];
|
|
320
|
+
const onKeycloak = new URL('http://localhost:8081/realms/notes/login');
|
|
321
|
+
const onApp = new URL('http://localhost:5173/');
|
|
322
|
+
const page: KeycloakLoginPage = {
|
|
323
|
+
waitForURL: (matches, options) => {
|
|
324
|
+
const timeout = options?.timeout === undefined ? 'default' : String(options.timeout);
|
|
325
|
+
steps.push(`wait ${String(matches(onKeycloak))} ${String(matches(onApp))} ${timeout}`);
|
|
326
|
+
return Promise.resolve();
|
|
327
|
+
},
|
|
328
|
+
locator: (selector) => ({
|
|
329
|
+
fill: (value) => {
|
|
330
|
+
steps.push(`fill ${selector} ${value}`);
|
|
331
|
+
return Promise.resolve();
|
|
332
|
+
},
|
|
333
|
+
click: () => {
|
|
334
|
+
steps.push(`click ${selector}`);
|
|
335
|
+
return Promise.resolve();
|
|
336
|
+
},
|
|
337
|
+
}),
|
|
338
|
+
};
|
|
339
|
+
return { page, steps };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
it('waits for the Keycloak origin and submits the form by its IDs', async () => {
|
|
343
|
+
const { page, steps } = fakePage();
|
|
344
|
+
|
|
345
|
+
await signInWithKeycloak(page, { username: 'ada', password: 'pw' }, STACK, {
|
|
346
|
+
timeoutMs: 60_000,
|
|
347
|
+
});
|
|
348
|
+
await signInWithKeycloak(page, { username: 'ada', password: 'pw' }, STACK);
|
|
349
|
+
|
|
350
|
+
expect(steps).toEqual([
|
|
351
|
+
'wait true false 60000',
|
|
352
|
+
'fill #username ada',
|
|
353
|
+
'fill #password pw',
|
|
354
|
+
'click #kc-login',
|
|
355
|
+
'wait true false default',
|
|
356
|
+
'fill #username ada',
|
|
357
|
+
'fill #password pw',
|
|
358
|
+
'click #kc-login',
|
|
359
|
+
]);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('accepts a Playwright page', () => {
|
|
363
|
+
const asLoginPage = (page: Page): KeycloakLoginPage => page;
|
|
364
|
+
expect(asLoginPage).toBeTypeOf('function');
|
|
365
|
+
});
|
|
366
|
+
});
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/** Where a disposable development Keycloak runs and how to administer it. */
|
|
4
|
+
export interface KeycloakStack {
|
|
5
|
+
readonly url: string;
|
|
6
|
+
readonly realm: string;
|
|
7
|
+
readonly adminUsername: string;
|
|
8
|
+
readonly adminPassword: string;
|
|
9
|
+
readonly webClientId: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The product's development defaults; `E2E_KEYCLOAK_*` variables override each one. */
|
|
13
|
+
export interface KeycloakStackDefaults {
|
|
14
|
+
readonly url: string;
|
|
15
|
+
readonly realm: string;
|
|
16
|
+
readonly webClientId: string;
|
|
17
|
+
/** Defaults to `admin`. */
|
|
18
|
+
readonly adminUsername?: string;
|
|
19
|
+
/** Defaults to `admin`. */
|
|
20
|
+
readonly adminPassword?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface KeycloakTestUser {
|
|
24
|
+
readonly username: string;
|
|
25
|
+
readonly password: string;
|
|
26
|
+
readonly email: string;
|
|
27
|
+
/** Keycloak user ID, which becomes the `sub` claim of the user's tokens. */
|
|
28
|
+
readonly subject: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface KeycloakTestUserOptions {
|
|
32
|
+
/** Defaults to `e2e-<uuid>`, so parallel tests never share an identity. */
|
|
33
|
+
readonly username?: string;
|
|
34
|
+
/** Defaults to a random password. */
|
|
35
|
+
readonly password?: string;
|
|
36
|
+
/** Defaults to `<username>@example.test`. */
|
|
37
|
+
readonly email?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface KeycloakRequestOptions {
|
|
41
|
+
/** Defaults to the global `fetch`. */
|
|
42
|
+
readonly fetch?: typeof fetch;
|
|
43
|
+
/** Per-request timeout. Defaults to {@link DEFAULT_KEYCLOAK_REQUEST_TIMEOUT_MS}. */
|
|
44
|
+
readonly timeoutMs?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The part of a Playwright `Page` that {@link signInWithKeycloak} uses. */
|
|
48
|
+
export interface KeycloakLoginPage {
|
|
49
|
+
waitForURL(url: (url: URL) => boolean, options?: { timeout?: number }): Promise<unknown>;
|
|
50
|
+
locator(selector: string): {
|
|
51
|
+
fill(value: string): Promise<unknown>;
|
|
52
|
+
click(): Promise<unknown>;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface KeycloakSignInOptions {
|
|
57
|
+
/** How long to wait for the login page. Defaults to the page's own timeout. */
|
|
58
|
+
readonly timeoutMs?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const DEFAULT_KEYCLOAK_REQUEST_TIMEOUT_MS = 30_000;
|
|
62
|
+
|
|
63
|
+
const HTTP_CREATED = 201;
|
|
64
|
+
const DEFAULT_ADMIN = 'admin';
|
|
65
|
+
|
|
66
|
+
/** Returns the stack from `E2E_KEYCLOAK_*` variables, falling back to the defaults. */
|
|
67
|
+
export function keycloakStack(
|
|
68
|
+
defaults: KeycloakStackDefaults,
|
|
69
|
+
environment: NodeJS.ProcessEnv = process.env,
|
|
70
|
+
): KeycloakStack {
|
|
71
|
+
const url = environment['E2E_KEYCLOAK_URL'] ?? defaults.url;
|
|
72
|
+
return {
|
|
73
|
+
url: url.replace(/\/+$/u, ''),
|
|
74
|
+
realm: environment['E2E_KEYCLOAK_REALM'] ?? defaults.realm,
|
|
75
|
+
adminUsername:
|
|
76
|
+
environment['E2E_KEYCLOAK_ADMIN_USERNAME'] ?? defaults.adminUsername ?? DEFAULT_ADMIN,
|
|
77
|
+
adminPassword:
|
|
78
|
+
environment['E2E_KEYCLOAK_ADMIN_PASSWORD'] ?? defaults.adminPassword ?? DEFAULT_ADMIN,
|
|
79
|
+
webClientId: environment['E2E_KEYCLOAK_WEB_CLIENT_ID'] ?? defaults.webClientId,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Creates a verified user with a password in the stack's realm. */
|
|
84
|
+
export async function createKeycloakTestUser(
|
|
85
|
+
stack: KeycloakStack,
|
|
86
|
+
user: KeycloakTestUserOptions = {},
|
|
87
|
+
options: KeycloakRequestOptions = {},
|
|
88
|
+
): Promise<KeycloakTestUser> {
|
|
89
|
+
const username = user.username ?? `e2e-${randomUUID()}`;
|
|
90
|
+
const password = user.password ?? `E2e-${randomUUID()}`;
|
|
91
|
+
const email = user.email ?? `${username}@example.test`;
|
|
92
|
+
const admin = await adminRequester(stack, options);
|
|
93
|
+
const response = await admin('users', {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
body: {
|
|
96
|
+
username,
|
|
97
|
+
email,
|
|
98
|
+
emailVerified: true,
|
|
99
|
+
enabled: true,
|
|
100
|
+
firstName: 'E2E',
|
|
101
|
+
lastName: 'User',
|
|
102
|
+
credentials: [{ type: 'password', value: password, temporary: false }],
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
if (response.status !== HTTP_CREATED) {
|
|
106
|
+
throw new Error(`Keycloak user creation failed with HTTP ${String(response.status)}.`);
|
|
107
|
+
}
|
|
108
|
+
const subject = response.headers.get('location')?.match(/\/([^/]+)$/u)?.[1];
|
|
109
|
+
if (subject === undefined) {
|
|
110
|
+
throw new Error('Keycloak user creation returned no user location.');
|
|
111
|
+
}
|
|
112
|
+
return { username, password, email, subject: decodeURIComponent(subject) };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Ends every Keycloak session of the user, so the next token refresh fails. */
|
|
116
|
+
export async function revokeKeycloakUserSessions(
|
|
117
|
+
stack: KeycloakStack,
|
|
118
|
+
subject: string,
|
|
119
|
+
options: KeycloakRequestOptions = {},
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
const admin = await adminRequester(stack, options);
|
|
122
|
+
const response = await admin(`users/${encodeURIComponent(subject)}/logout`, { method: 'POST' });
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
throw new Error(`Keycloak session revocation failed with HTTP ${String(response.status)}.`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface KeycloakClient {
|
|
129
|
+
readonly id: string;
|
|
130
|
+
readonly redirectUris?: readonly string[];
|
|
131
|
+
readonly webOrigins?: readonly string[];
|
|
132
|
+
readonly attributes?: Readonly<Record<string, string>>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const POST_LOGOUT_REDIRECT_URIS = 'post.logout.redirect.uris';
|
|
136
|
+
const POST_LOGOUT_SEPARATOR = '##';
|
|
137
|
+
const SAME_AS_REDIRECT_URIS = '+';
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Adds the origin to the web client's redirect URIs, web origins, and post-logout redirect URIs
|
|
141
|
+
* when the client lacks it.
|
|
142
|
+
*/
|
|
143
|
+
export async function allowKeycloakWebOrigin(
|
|
144
|
+
stack: KeycloakStack,
|
|
145
|
+
origin: string,
|
|
146
|
+
options: KeycloakRequestOptions = {},
|
|
147
|
+
): Promise<void> {
|
|
148
|
+
const admin = await adminRequester(stack, options);
|
|
149
|
+
const lookup = await admin(`clients?clientId=${encodeURIComponent(stack.webClientId)}`);
|
|
150
|
+
if (!lookup.ok) {
|
|
151
|
+
throw new Error(`Keycloak client lookup failed with HTTP ${String(lookup.status)}.`);
|
|
152
|
+
}
|
|
153
|
+
const [client] = (await lookup.json()) as readonly (KeycloakClient & Record<string, unknown>)[];
|
|
154
|
+
if (client === undefined) {
|
|
155
|
+
throw new Error(`Keycloak realm ${stack.realm} has no ${stack.webClientId} client.`);
|
|
156
|
+
}
|
|
157
|
+
const redirectUri = `${origin}/*`;
|
|
158
|
+
const postLogoutRedirectUris = postLogoutEntries(client);
|
|
159
|
+
const logoutAllowed =
|
|
160
|
+
postLogoutRedirectUris.includes(SAME_AS_REDIRECT_URIS) ||
|
|
161
|
+
postLogoutRedirectUris.includes(redirectUri);
|
|
162
|
+
if (
|
|
163
|
+
client.redirectUris?.includes(redirectUri) &&
|
|
164
|
+
client.webOrigins?.includes(origin) &&
|
|
165
|
+
logoutAllowed
|
|
166
|
+
) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const update = await admin(`clients/${encodeURIComponent(client.id)}`, {
|
|
170
|
+
method: 'PUT',
|
|
171
|
+
body: {
|
|
172
|
+
...client,
|
|
173
|
+
redirectUris: withEntry(client.redirectUris, redirectUri),
|
|
174
|
+
webOrigins: withEntry(client.webOrigins, origin),
|
|
175
|
+
attributes: {
|
|
176
|
+
...client.attributes,
|
|
177
|
+
[POST_LOGOUT_REDIRECT_URIS]: (logoutAllowed
|
|
178
|
+
? postLogoutRedirectUris
|
|
179
|
+
: [...postLogoutRedirectUris, redirectUri]
|
|
180
|
+
).join(POST_LOGOUT_SEPARATOR),
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
if (!update.ok) {
|
|
185
|
+
throw new Error(`Keycloak client update failed with HTTP ${String(update.status)}.`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Waits for the Keycloak login page, fills it by its stable `keycloak.v2` IDs, and submits. */
|
|
190
|
+
export async function signInWithKeycloak(
|
|
191
|
+
loginPage: KeycloakLoginPage,
|
|
192
|
+
user: Pick<KeycloakTestUser, 'username' | 'password'>,
|
|
193
|
+
stack: KeycloakStack,
|
|
194
|
+
options: KeycloakSignInOptions = {},
|
|
195
|
+
): Promise<void> {
|
|
196
|
+
const origin = new URL(stack.url).origin;
|
|
197
|
+
const isKeycloak = (url: URL): boolean => url.origin === origin;
|
|
198
|
+
await (options.timeoutMs === undefined
|
|
199
|
+
? loginPage.waitForURL(isKeycloak)
|
|
200
|
+
: loginPage.waitForURL(isKeycloak, { timeout: options.timeoutMs }));
|
|
201
|
+
await loginPage.locator('#username').fill(user.username);
|
|
202
|
+
await loginPage.locator('#password').fill(user.password);
|
|
203
|
+
await loginPage.locator('#kc-login').click();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
interface AdminRequest {
|
|
207
|
+
readonly method?: 'GET' | 'POST' | 'PUT';
|
|
208
|
+
readonly body?: unknown;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
type AdminRequester = (path: string, request?: AdminRequest) => Promise<Response>;
|
|
212
|
+
|
|
213
|
+
async function adminRequester(
|
|
214
|
+
stack: KeycloakStack,
|
|
215
|
+
options: KeycloakRequestOptions,
|
|
216
|
+
): Promise<AdminRequester> {
|
|
217
|
+
const fetcher = options.fetch ?? fetch;
|
|
218
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_KEYCLOAK_REQUEST_TIMEOUT_MS;
|
|
219
|
+
const token = await adminAccessToken(stack, fetcher, timeoutMs);
|
|
220
|
+
const realmUrl = `${stack.url}/admin/realms/${encodeURIComponent(stack.realm)}`;
|
|
221
|
+
return (path, request = {}) =>
|
|
222
|
+
fetcher(`${realmUrl}/${path}`, {
|
|
223
|
+
method: request.method ?? 'GET',
|
|
224
|
+
headers: {
|
|
225
|
+
authorization: `Bearer ${token}`,
|
|
226
|
+
...(request.body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
227
|
+
},
|
|
228
|
+
...(request.body === undefined ? {} : { body: JSON.stringify(request.body) }),
|
|
229
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function adminAccessToken(
|
|
234
|
+
stack: KeycloakStack,
|
|
235
|
+
fetcher: typeof fetch,
|
|
236
|
+
timeoutMs: number,
|
|
237
|
+
): Promise<string> {
|
|
238
|
+
const response = await fetcher(`${stack.url}/realms/master/protocol/openid-connect/token`, {
|
|
239
|
+
method: 'POST',
|
|
240
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
241
|
+
body: new URLSearchParams({
|
|
242
|
+
client_id: 'admin-cli',
|
|
243
|
+
grant_type: 'password',
|
|
244
|
+
username: stack.adminUsername,
|
|
245
|
+
password: stack.adminPassword,
|
|
246
|
+
}),
|
|
247
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
248
|
+
});
|
|
249
|
+
if (!response.ok) {
|
|
250
|
+
throw new Error(`Keycloak admin sign-in failed with HTTP ${String(response.status)}.`);
|
|
251
|
+
}
|
|
252
|
+
const body = (await response.json()) as { readonly access_token?: unknown };
|
|
253
|
+
if (typeof body.access_token !== 'string') {
|
|
254
|
+
throw new Error('Keycloak admin sign-in returned no access token.');
|
|
255
|
+
}
|
|
256
|
+
return body.access_token;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function postLogoutEntries(client: KeycloakClient): string[] {
|
|
260
|
+
const value = client.attributes?.[POST_LOGOUT_REDIRECT_URIS] ?? '';
|
|
261
|
+
return value.split(POST_LOGOUT_SEPARATOR).filter((entry) => entry !== '');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function withEntry(entries: readonly string[] | undefined, entry: string): string[] {
|
|
265
|
+
const current = entries ?? [];
|
|
266
|
+
return current.includes(entry) ? [...current] : [...current, entry];
|
|
267
|
+
}
|