@baukit/auth-node 0.7.1 → 0.7.3

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/src/errors.ts ADDED
@@ -0,0 +1,64 @@
1
+ export type AuthNodeErrorCode =
2
+ | 'aborted'
3
+ | 'cache_corrupt'
4
+ | 'cache_lock_timeout'
5
+ | 'cache_permission'
6
+ | 'cache_read_failed'
7
+ | 'cache_symlink'
8
+ | 'cache_write_failed'
9
+ | 'device_authorization_denied'
10
+ | 'device_authorization_expired'
11
+ | 'device_authorization_failed'
12
+ | 'discovery_failed'
13
+ | 'endpoint_policy_violation'
14
+ | 'invalid_device_response'
15
+ | 'invalid_discovery_document'
16
+ | 'invalid_token_response'
17
+ | 'issuer_mismatch'
18
+ | 'login_timeout'
19
+ | 'no_cached_session'
20
+ | 'refresh_failed';
21
+
22
+ const SAFE_MESSAGES = {
23
+ aborted: 'OIDC operation was cancelled.',
24
+ cache_corrupt: 'The local authentication cache is invalid.',
25
+ cache_lock_timeout: 'Timed out waiting for the local authentication cache lock.',
26
+ cache_permission: 'The local authentication cache permissions are unsafe.',
27
+ cache_read_failed: 'Could not read the local authentication cache.',
28
+ cache_symlink: 'The local authentication cache must not use symbolic links.',
29
+ cache_write_failed: 'Could not update the local authentication cache.',
30
+ device_authorization_denied: 'OIDC device authorization was denied.',
31
+ device_authorization_expired: 'OIDC device authorization expired.',
32
+ device_authorization_failed: 'OIDC device authorization failed.',
33
+ discovery_failed: 'OIDC provider discovery failed.',
34
+ endpoint_policy_violation: 'OIDC endpoint did not satisfy the configured security policy.',
35
+ invalid_device_response: 'OIDC provider returned an invalid device authorization response.',
36
+ invalid_discovery_document: 'OIDC provider returned invalid discovery metadata.',
37
+ invalid_token_response: 'OIDC provider returned an invalid token response.',
38
+ issuer_mismatch: 'OIDC provider issuer did not match an allowed issuer.',
39
+ login_timeout: 'OIDC device authorization timed out.',
40
+ no_cached_session: 'No usable OIDC session is available.',
41
+ refresh_failed: 'OIDC token refresh failed.',
42
+ } as const satisfies Record<AuthNodeErrorCode, string>;
43
+
44
+ /** An authentication failure whose message never includes tokens or provider response bodies. */
45
+ export class AuthNodeError extends Error {
46
+ public readonly code: AuthNodeErrorCode;
47
+ public readonly retryable: boolean;
48
+ public readonly status: number | undefined;
49
+
50
+ public constructor(
51
+ code: AuthNodeErrorCode,
52
+ options: { readonly retryable?: boolean; readonly status?: number } = {},
53
+ ) {
54
+ super(SAFE_MESSAGES[code]);
55
+ this.name = 'AuthNodeError';
56
+ this.code = code;
57
+ this.retryable = options.retryable ?? false;
58
+ this.status = options.status;
59
+ }
60
+ }
61
+
62
+ export function safeAuthNodeErrorMessage(cause: unknown): string {
63
+ return cause instanceof AuthNodeError ? SAFE_MESSAGES[cause.code] : 'OIDC authentication failed.';
64
+ }
@@ -0,0 +1,578 @@
1
+ /// <reference types="node" />
2
+
3
+ import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import { afterEach, describe, expect, it, vi } from 'vitest';
8
+
9
+ import {
10
+ AuthNodeError,
11
+ decodeDisplayOnlyClaims,
12
+ DeviceFlowClient,
13
+ NodeTokenCache,
14
+ safeAuthNodeErrorMessage,
15
+ type CachedTokenProfile,
16
+ } from './index.js';
17
+
18
+ const ISSUER = 'https://identity.example.test/realms/example';
19
+ const DEVICE_ENDPOINT = `${ISSUER}/protocol/openid-connect/auth/device`;
20
+ const TOKEN_ENDPOINT = `${ISSUER}/protocol/openid-connect/token`;
21
+ const temporaryDirectories: string[] = [];
22
+
23
+ afterEach(async () => {
24
+ await Promise.all(
25
+ temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })),
26
+ );
27
+ });
28
+
29
+ async function cachePath(): Promise<string> {
30
+ const directory = await mkdtemp(join(tmpdir(), 'baukit-auth-node-'));
31
+ temporaryDirectories.push(directory);
32
+ return join(directory, 'tokens.json');
33
+ }
34
+
35
+ function json(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
36
+ return new Response(JSON.stringify(body), {
37
+ status,
38
+ headers: { 'content-type': 'application/json', ...headers },
39
+ });
40
+ }
41
+
42
+ function discovery(overrides: Record<string, unknown> = {}): Response {
43
+ return json({
44
+ issuer: ISSUER,
45
+ device_authorization_endpoint: DEVICE_ENDPOINT,
46
+ token_endpoint: TOKEN_ENDPOINT,
47
+ ...overrides,
48
+ });
49
+ }
50
+
51
+ function device(overrides: Record<string, unknown> = {}): Response {
52
+ return json({
53
+ device_code: 'device-secret',
54
+ user_code: 'ABCD-EFGH',
55
+ verification_uri: 'https://identity.example.test/realms/example/device',
56
+ verification_uri_complete:
57
+ 'https://identity.example.test/realms/example/device?user_code=ABCD-EFGH',
58
+ expires_in: 600,
59
+ interval: 1,
60
+ ...overrides,
61
+ });
62
+ }
63
+
64
+ function client(
65
+ path: string,
66
+ fetchImplementation: typeof fetch,
67
+ options: {
68
+ readonly now?: () => number;
69
+ readonly sleep?: (milliseconds: number, signal?: AbortSignal) => Promise<void>;
70
+ readonly requestTimeoutMs?: number;
71
+ readonly loginTimeoutMs?: number;
72
+ readonly issuer?: string;
73
+ readonly endpointPolicy?: { readonly allowLoopbackHttp?: boolean };
74
+ } = {},
75
+ ): DeviceFlowClient {
76
+ return new DeviceFlowClient(
77
+ {
78
+ issuer: options.issuer ?? ISSUER,
79
+ clientId: 'example-cli',
80
+ scopes: ['profile', 'custom', 'profile'],
81
+ audience: 'example-api',
82
+ cache: { namespace: 'example-cli', path },
83
+ refreshLeewaySeconds: 0,
84
+ ...(options.requestTimeoutMs === undefined
85
+ ? {}
86
+ : { requestTimeoutMs: options.requestTimeoutMs }),
87
+ ...(options.loginTimeoutMs === undefined ? {} : { loginTimeoutMs: options.loginTimeoutMs }),
88
+ ...(options.endpointPolicy === undefined ? {} : { endpointPolicy: options.endpointPolicy }),
89
+ },
90
+ {
91
+ fetch: fetchImplementation,
92
+ ...(options.now === undefined ? {} : { now: options.now }),
93
+ ...(options.sleep === undefined ? {} : { sleep: options.sleep }),
94
+ },
95
+ );
96
+ }
97
+
98
+ function form(init: RequestInit | undefined): URLSearchParams {
99
+ if (!(init?.body instanceof URLSearchParams)) throw new Error('Expected a form request.');
100
+ return init.body;
101
+ }
102
+
103
+ function profile(overrides: Partial<CachedTokenProfile> = {}): CachedTokenProfile {
104
+ return {
105
+ accessToken: 'old-access-secret',
106
+ refreshToken: 'old-refresh-secret',
107
+ tokenType: 'Bearer',
108
+ expiresAt: 1,
109
+ issuer: ISSUER,
110
+ clientId: 'example-cli',
111
+ scopes: ['openid', 'profile', 'custom'],
112
+ audience: 'example-api',
113
+ ...overrides,
114
+ };
115
+ }
116
+
117
+ describe('device authorization', () => {
118
+ it('uses S256 PKCE, handles pending and slow-down, and accepts an optional refresh token', async () => {
119
+ const path = await cachePath();
120
+ const requests: { readonly url: string; readonly init: RequestInit | undefined }[] = [];
121
+ const sleeps: number[] = [];
122
+ const statuses: string[] = [];
123
+ let now = 1_000;
124
+ let tokenPoll = 0;
125
+ const authFetch: typeof fetch = (input, init) => {
126
+ const url = requestUrl(input);
127
+ requests.push({ url, init });
128
+ if (url.includes('.well-known')) return Promise.resolve(discovery());
129
+ if (url === DEVICE_ENDPOINT) return Promise.resolve(device());
130
+ tokenPoll += 1;
131
+ if (tokenPoll === 1) return Promise.resolve(json({ error: 'authorization_pending' }, 400));
132
+ if (tokenPoll === 2) return Promise.resolve(json({ error: 'slow_down' }, 400));
133
+ return Promise.resolve(json({ access_token: 'new-access-secret', expires_in: 120 }));
134
+ };
135
+ const auth = client(path, authFetch, {
136
+ now: () => now,
137
+ sleep: (milliseconds) => {
138
+ sleeps.push(milliseconds);
139
+ now += milliseconds;
140
+ return Promise.resolve();
141
+ },
142
+ });
143
+ const showVerification = vi.fn();
144
+ const openBrowser = vi.fn();
145
+
146
+ const cached = await auth.login({
147
+ presentation: {
148
+ showVerification,
149
+ openBrowser,
150
+ showStatus: (status) => {
151
+ statuses.push(status);
152
+ },
153
+ },
154
+ });
155
+
156
+ expect(cached).toMatchObject({ accessToken: 'new-access-secret', expiresAt: 129_000 });
157
+ expect(cached.refreshToken).toBeUndefined();
158
+ expect(sleeps).toEqual([1_000, 1_000, 6_000]);
159
+ expect(statuses).toEqual(['waiting', 'slow-down', 'authorized']);
160
+ expect(showVerification).toHaveBeenCalledWith({
161
+ verificationUri: 'https://identity.example.test/realms/example/device',
162
+ verificationUriComplete:
163
+ 'https://identity.example.test/realms/example/device?user_code=ABCD-EFGH',
164
+ userCode: 'ABCD-EFGH',
165
+ });
166
+ expect(openBrowser).toHaveBeenCalledWith(
167
+ 'https://identity.example.test/realms/example/device?user_code=ABCD-EFGH',
168
+ );
169
+ const deviceRequest = form(requests[1]?.init);
170
+ const tokenRequest = form(requests.at(-1)?.init);
171
+ expect(deviceRequest.get('scope')).toBe('openid profile custom');
172
+ expect(deviceRequest.get('audience')).toBe('example-api');
173
+ expect(deviceRequest.get('code_challenge_method')).toBe('S256');
174
+ expect(deviceRequest.get('code_challenge')).toMatch(/^[A-Za-z0-9_-]{43}$/u);
175
+ expect(tokenRequest.get('code_verifier')).toMatch(/^[A-Za-z0-9_-]{43}$/u);
176
+ expect(tokenRequest.get('code_verifier')).not.toBe(deviceRequest.get('code_challenge'));
177
+ });
178
+
179
+ it.each([
180
+ ['access_denied', 'device_authorization_denied'],
181
+ ['expired_token', 'device_authorization_expired'],
182
+ ['invalid_grant', 'device_authorization_failed'],
183
+ ] as const)('maps %s to %s without provider details', async (providerCode, expectedCode) => {
184
+ const authFetch: typeof fetch = (input) => {
185
+ const url = requestUrl(input);
186
+ if (url.includes('.well-known')) return Promise.resolve(discovery());
187
+ if (url === DEVICE_ENDPOINT) return Promise.resolve(device());
188
+ return Promise.resolve(
189
+ json({ error: providerCode, error_description: 'token=private' }, 400),
190
+ );
191
+ };
192
+ const auth = client(await cachePath(), authFetch, { sleep: () => Promise.resolve() });
193
+ const error = await auth.login().catch((cause: unknown) => cause);
194
+ expect(error).toMatchObject({ code: expectedCode });
195
+ expect(String(error)).not.toContain('private');
196
+ });
197
+
198
+ it('expires locally when the device lifetime elapses', async () => {
199
+ let now = 0;
200
+ const authFetch: typeof fetch = (input) => {
201
+ const url = requestUrl(input);
202
+ if (url.includes('.well-known')) return Promise.resolve(discovery());
203
+ return Promise.resolve(device({ expires_in: 1 }));
204
+ };
205
+ const auth = client(await cachePath(), authFetch, {
206
+ now: () => now,
207
+ sleep: (milliseconds) => {
208
+ now += milliseconds;
209
+ return Promise.resolve();
210
+ },
211
+ });
212
+ await expect(auth.login()).rejects.toMatchObject({ code: 'device_authorization_expired' });
213
+ });
214
+
215
+ it.each([
216
+ [{ expires_in: '600' }, 'invalid_device_response'],
217
+ [{ expires_in: Number.NaN }, 'invalid_device_response'],
218
+ [{ interval: 0 }, 'invalid_device_response'],
219
+ [{ interval: 1.5 }, 'invalid_device_response'],
220
+ ])('rejects malformed device numeric fields %#', async (overrides, code) => {
221
+ const authFetch: typeof fetch = (input) =>
222
+ Promise.resolve(requestUrl(input).includes('.well-known') ? discovery() : device(overrides));
223
+ await expect(client(await cachePath(), authFetch).login()).rejects.toMatchObject({ code });
224
+ });
225
+
226
+ it('rejects a malformed token lifetime', async () => {
227
+ const authFetch: typeof fetch = (input) => {
228
+ const url = requestUrl(input);
229
+ if (url.includes('.well-known')) return Promise.resolve(discovery());
230
+ if (url === DEVICE_ENDPOINT) return Promise.resolve(device());
231
+ return Promise.resolve(json({ access_token: 'private-token', expires_in: '300' }));
232
+ };
233
+ await expect(
234
+ client(await cachePath(), authFetch, { sleep: () => Promise.resolve() }).login(),
235
+ ).rejects.toMatchObject({ code: 'invalid_token_response' });
236
+ });
237
+ });
238
+
239
+ describe('issuer and endpoint policy', () => {
240
+ it('rejects an issuer mismatch unless the discovered alias is explicitly allowed', async () => {
241
+ const alias = 'https://identity.example.test/realms/alias';
242
+ const authFetch: typeof fetch = () =>
243
+ Promise.resolve(
244
+ discovery({
245
+ issuer: alias,
246
+ token_endpoint: `${alias}/token`,
247
+ device_authorization_endpoint: `${alias}/device`,
248
+ }),
249
+ );
250
+ const path = await cachePath();
251
+ await expect(client(path, authFetch).discover()).rejects.toMatchObject({
252
+ code: 'issuer_mismatch',
253
+ });
254
+ const allowed = new DeviceFlowClient(
255
+ {
256
+ issuer: ISSUER,
257
+ clientId: 'client',
258
+ cache: { namespace: 'test', path },
259
+ endpointPolicy: { issuerAllowlist: [alias] },
260
+ },
261
+ { fetch: authFetch },
262
+ );
263
+ await expect(allowed.discover()).resolves.toMatchObject({ issuer: alias });
264
+ });
265
+
266
+ it.each([
267
+ ['http://identity.example.test/realms/example/token'],
268
+ ['https://other.example.test/token'],
269
+ ['https://identity.example.test/unrelated/token'],
270
+ ])('rejects unsafe or unrelated endpoint %s', async (tokenEndpoint) => {
271
+ const authFetch: typeof fetch = () =>
272
+ Promise.resolve(discovery({ token_endpoint: tokenEndpoint }));
273
+ await expect(client(await cachePath(), authFetch).discover()).rejects.toMatchObject({
274
+ code: 'endpoint_policy_violation',
275
+ });
276
+ });
277
+
278
+ it('allows HTTP only for an explicit loopback development policy', async () => {
279
+ const loopbackIssuer = 'http://127.0.0.1:9999/realms/test';
280
+ const authFetch: typeof fetch = () =>
281
+ Promise.resolve(
282
+ json({
283
+ issuer: loopbackIssuer,
284
+ token_endpoint: `${loopbackIssuer}/token`,
285
+ device_authorization_endpoint: `${loopbackIssuer}/device`,
286
+ }),
287
+ );
288
+ expect(() => client('/tmp/not-read', authFetch, { issuer: loopbackIssuer })).toThrow(
289
+ AuthNodeError,
290
+ );
291
+ await expect(
292
+ client(await cachePath(), authFetch, {
293
+ issuer: loopbackIssuer,
294
+ endpointPolicy: { allowLoopbackHttp: true },
295
+ }).discover(),
296
+ ).resolves.toMatchObject({ issuer: loopbackIssuer });
297
+ });
298
+ });
299
+
300
+ describe('response and cancellation bounds', () => {
301
+ it('rejects declared and streamed oversized responses without retaining their contents', async () => {
302
+ const declared: typeof fetch = () =>
303
+ Promise.resolve(
304
+ new Response('{}', { headers: { 'content-length': '70000', 'x-private': 'secret' } }),
305
+ );
306
+ await expect(client(await cachePath(), declared).discover()).rejects.toMatchObject({
307
+ code: 'invalid_discovery_document',
308
+ });
309
+
310
+ const streamed: typeof fetch = () => Promise.resolve(json({ padding: 'x'.repeat(70_000) }));
311
+ const error = await client(await cachePath(), streamed)
312
+ .discover()
313
+ .catch((cause: unknown) => cause);
314
+ expect(error).toMatchObject({ code: 'invalid_discovery_document' });
315
+ expect(String(error)).not.toContain('xxxxx');
316
+ });
317
+
318
+ it('supports caller abort during polling', async () => {
319
+ const controller = new AbortController();
320
+ const authFetch: typeof fetch = (input) =>
321
+ Promise.resolve(requestUrl(input).includes('.well-known') ? discovery() : device());
322
+ const auth = client(await cachePath(), authFetch, {
323
+ sleep: (_milliseconds, signal) => {
324
+ controller.abort();
325
+ return signal?.aborted === true
326
+ ? Promise.reject(new AuthNodeError('aborted'))
327
+ : Promise.resolve();
328
+ },
329
+ });
330
+ await expect(auth.login({ signal: controller.signal })).rejects.toMatchObject({
331
+ code: 'aborted',
332
+ });
333
+ });
334
+
335
+ it('enforces the per-request timeout', async () => {
336
+ const never: typeof fetch = (_input, init) =>
337
+ new Promise((_resolve, reject) => {
338
+ init?.signal?.addEventListener('abort', () => {
339
+ reject(new DOMException('aborted', 'AbortError'));
340
+ });
341
+ });
342
+ await expect(
343
+ client(await cachePath(), never, { requestTimeoutMs: 5, loginTimeoutMs: 1_000 }).login(),
344
+ ).rejects.toMatchObject({ code: 'login_timeout' });
345
+ });
346
+
347
+ it('enforces the total login timeout while polling', async () => {
348
+ const authFetch: typeof fetch = (input) =>
349
+ Promise.resolve(requestUrl(input).includes('.well-known') ? discovery() : device());
350
+ await expect(
351
+ client(await cachePath(), authFetch, {
352
+ loginTimeoutMs: 5,
353
+ sleep: (_milliseconds, signal) =>
354
+ new Promise((_resolve, reject) => {
355
+ signal?.addEventListener('abort', () => {
356
+ reject(new DOMException('aborted', 'AbortError'));
357
+ });
358
+ }),
359
+ }).login(),
360
+ ).rejects.toMatchObject({ code: 'login_timeout' });
361
+ });
362
+ });
363
+
364
+ describe('refresh lifecycle', () => {
365
+ it('deduplicates concurrent refreshes across clients and preserves an omitted refresh token', async () => {
366
+ const path = await cachePath();
367
+ const cache = new NodeTokenCache({ namespace: 'example-cli', path });
368
+ await cache.write('default', profile());
369
+ let refreshCalls = 0;
370
+ const authFetch: typeof fetch = async (input) => {
371
+ const url = requestUrl(input);
372
+ if (url.includes('.well-known')) return discovery();
373
+ refreshCalls += 1;
374
+ await Promise.resolve();
375
+ return json({ access_token: 'refreshed-access-secret', expires_in: 300 });
376
+ };
377
+ const first = client(path, authFetch, { now: () => 10_000 });
378
+ const second = client(path, authFetch, { now: () => 10_000 });
379
+
380
+ await expect(
381
+ Promise.all([first.accessToken(), first.accessToken(), second.accessToken()]),
382
+ ).resolves.toEqual([
383
+ 'refreshed-access-secret',
384
+ 'refreshed-access-secret',
385
+ 'refreshed-access-secret',
386
+ ]);
387
+ expect(refreshCalls).toBe(1);
388
+ expect(await cache.read()).toMatchObject({
389
+ accessToken: 'refreshed-access-secret',
390
+ refreshToken: 'old-refresh-secret',
391
+ });
392
+ });
393
+
394
+ it('stores a rotated refresh token', async () => {
395
+ const path = await cachePath();
396
+ const cache = new NodeTokenCache({ namespace: 'example-cli', path });
397
+ await cache.write('default', profile());
398
+ const authFetch: typeof fetch = (input) =>
399
+ Promise.resolve(
400
+ requestUrl(input).includes('.well-known')
401
+ ? discovery()
402
+ : json({
403
+ access_token: 'rotated-access-secret',
404
+ refresh_token: 'rotated-refresh-secret',
405
+ expires_in: 300,
406
+ }),
407
+ );
408
+ await expect(client(path, authFetch, { now: () => 10_000 }).accessToken()).resolves.toBe(
409
+ 'rotated-access-secret',
410
+ );
411
+ expect(await cache.read()).toMatchObject({ refreshToken: 'rotated-refresh-secret' });
412
+ });
413
+
414
+ it('uses an injected environment token before reading the cache', async () => {
415
+ const auth = new DeviceFlowClient(
416
+ {
417
+ issuer: ISSUER,
418
+ clientId: 'client',
419
+ cache: { namespace: 'test', path: '/does/not/exist/tokens.json' },
420
+ },
421
+ { environmentToken: () => ' environment-secret ' },
422
+ );
423
+ await expect(auth.accessToken()).resolves.toBe('environment-secret');
424
+ });
425
+
426
+ it('does not use a cache profile bound to different client inputs', async () => {
427
+ const path = await cachePath();
428
+ await new NodeTokenCache({ namespace: 'example-cli', path }).write(
429
+ 'default',
430
+ profile({
431
+ expiresAt: 100_000,
432
+ scopes: ['openid', 'profile', 'custom'],
433
+ audience: 'different-api',
434
+ }),
435
+ );
436
+ await expect(client(path, vi.fn(), { now: () => 10_000 }).accessToken()).rejects.toMatchObject({
437
+ code: 'no_cached_session',
438
+ });
439
+ });
440
+
441
+ it('maps a refresh transport failure to a stable error', async () => {
442
+ const path = await cachePath();
443
+ await new NodeTokenCache({ namespace: 'example-cli', path }).write('default', profile());
444
+ const authFetch: typeof fetch = (input) => {
445
+ if (requestUrl(input).includes('.well-known')) return Promise.resolve(discovery());
446
+ return Promise.reject(new TypeError('request included private provider text'));
447
+ };
448
+ const error = await client(path, authFetch, { now: () => 10_000 })
449
+ .accessToken()
450
+ .catch((cause: unknown) => cause);
451
+ expect(error).toMatchObject({ code: 'refresh_failed', retryable: true });
452
+ expect(String(error)).not.toContain('private provider text');
453
+ });
454
+ });
455
+
456
+ describe('cache safety', () => {
457
+ it('stores separate profiles and logs out only the selected profile', async () => {
458
+ const path = await cachePath();
459
+ const cache = new NodeTokenCache({ namespace: 'example-cli', path });
460
+ await cache.write('work', profile({ accessToken: 'work-secret' }));
461
+ await cache.write('personal', profile({ accessToken: 'personal-secret' }));
462
+ await expect(cache.remove('work')).resolves.toBe(true);
463
+ await expect(cache.remove('work')).resolves.toBe(false);
464
+ expect(await cache.read('work')).toBeUndefined();
465
+ expect(await cache.read('personal')).toMatchObject({ accessToken: 'personal-secret' });
466
+ });
467
+
468
+ it('reports corrupt cache data with a stable safe error', async () => {
469
+ const path = await cachePath();
470
+ await writeFile(path, '{"accessToken":"private-secret"', { mode: 0o600 });
471
+ const error = await new NodeTokenCache({ namespace: 'test', path })
472
+ .read()
473
+ .catch((cause: unknown) => cause);
474
+ expect(error).toMatchObject({ code: 'cache_corrupt' });
475
+ expect(safeAuthNodeErrorMessage(error)).not.toContain('private-secret');
476
+ });
477
+
478
+ it('rejects group-readable cache files on POSIX and ignores mode bits on Windows', async () => {
479
+ const path = await cachePath();
480
+ const cache = new NodeTokenCache({ namespace: 'test', path });
481
+ await cache.write('default', profile());
482
+ await chmod(path, 0o644);
483
+ await expect(cache.read()).rejects.toMatchObject({ code: 'cache_permission' });
484
+ await expect(
485
+ new NodeTokenCache({ namespace: 'test', path, platform: 'win32' }).read(),
486
+ ).resolves.toMatchObject({
487
+ accessToken: 'old-access-secret',
488
+ });
489
+ });
490
+
491
+ it('rejects a symlink cache and a symlink parent', async () => {
492
+ if (process.platform === 'win32') return;
493
+ const directory = await mkdtemp(join(tmpdir(), 'baukit-auth-node-links-'));
494
+ temporaryDirectories.push(directory);
495
+ const target = join(directory, 'target.json');
496
+ await writeFile(target, '{}', { mode: 0o600 });
497
+ const linkedFile = join(directory, 'linked.json');
498
+ await symlink(target, linkedFile);
499
+ await expect(
500
+ new NodeTokenCache({ namespace: 'test', path: linkedFile }).read(),
501
+ ).rejects.toMatchObject({
502
+ code: 'cache_symlink',
503
+ });
504
+ const realDirectory = join(directory, 'real');
505
+ await mkdir(realDirectory, { mode: 0o700 });
506
+ const linkedDirectory = join(directory, 'linked-directory');
507
+ await symlink(realDirectory, linkedDirectory);
508
+ await expect(
509
+ new NodeTokenCache({ namespace: 'test', path: join(linkedDirectory, 'tokens.json') }).write(
510
+ 'default',
511
+ profile(),
512
+ ),
513
+ ).rejects.toMatchObject({ code: 'cache_symlink' });
514
+ });
515
+
516
+ it('preserves the old cache when a replacement is interrupted by a permission failure', async () => {
517
+ if (process.platform === 'win32') return;
518
+ const path = await cachePath();
519
+ const cache = new NodeTokenCache({ namespace: 'test', path });
520
+ await cache.write('default', profile());
521
+ const directory = join(path, '..');
522
+ await chmod(directory, 0o500);
523
+ const failure = await cache
524
+ .write('default', profile({ accessToken: 'replacement-secret' }))
525
+ .catch((cause: unknown) => cause);
526
+ await chmod(directory, 0o700);
527
+ expect(failure).toMatchObject({ code: 'cache_permission' });
528
+ expect(await cache.read()).toMatchObject({ accessToken: 'old-access-secret' });
529
+ expect(await readFile(path, 'utf8')).not.toContain('replacement-secret');
530
+ });
531
+
532
+ it('times out on an existing cross-process lock', async () => {
533
+ const path = await cachePath();
534
+ await writeFile(`${path}.lock`, 'locked', { mode: 0o600 });
535
+ let now = 0;
536
+ const cache = new NodeTokenCache({
537
+ namespace: 'test',
538
+ path,
539
+ now: () => now,
540
+ sleep: (milliseconds) => {
541
+ now += milliseconds;
542
+ return Promise.resolve();
543
+ },
544
+ lockTimeoutMs: 2,
545
+ lockRetryMs: 1,
546
+ });
547
+ await expect(cache.write('default', profile())).rejects.toMatchObject({
548
+ code: 'cache_lock_timeout',
549
+ });
550
+ });
551
+ });
552
+
553
+ describe('display-only claims', () => {
554
+ it('decodes only named unverified display hints', () => {
555
+ const encoded = Buffer.from(
556
+ JSON.stringify({
557
+ sub: 'user-1',
558
+ name: 'Test User',
559
+ preferred_username: 'test',
560
+ email: 'test@example.test',
561
+ exp: 123,
562
+ roles: ['admin'],
563
+ }),
564
+ ).toString('base64url');
565
+ expect(decodeDisplayOnlyClaims(`header.${encoded}.signature`)).toEqual({
566
+ subject: 'user-1',
567
+ name: 'Test User',
568
+ preferredUsername: 'test',
569
+ email: 'test@example.test',
570
+ expiresAt: 123,
571
+ });
572
+ expect(decodeDisplayOnlyClaims('not-a-jwt')).toBeUndefined();
573
+ });
574
+ });
575
+
576
+ function requestUrl(input: string | URL | Request): string {
577
+ return input instanceof Request ? input.url : input.toString();
578
+ }
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ export {
2
+ ApiOriginError,
3
+ parseApiOrigin,
4
+ type ApiOriginErrorReason,
5
+ type ApiOriginOptions,
6
+ } from './api-origin.js';
7
+ export {
8
+ defaultTokenCachePath,
9
+ NodeTokenCache,
10
+ type CachedTokenProfile,
11
+ type NodeTokenCacheOptions,
12
+ type TokenCacheTransaction,
13
+ } from './cache.js';
14
+ export {
15
+ decodeDisplayOnlyClaims,
16
+ DeviceFlowClient,
17
+ discoverDeviceProvider,
18
+ type AccessTokenOptions,
19
+ type DeviceFlowClientConfig,
20
+ type DeviceFlowEnvironment,
21
+ type DeviceFlowPresentation,
22
+ type DeviceFlowStatus,
23
+ type DeviceProviderRequestOptions,
24
+ type DeviceVerification,
25
+ type DisplayOnlyClaims,
26
+ type EndpointPolicy,
27
+ type LoginOptions,
28
+ type OidcDeviceMetadata,
29
+ } from './device-flow.js';
30
+ export { AuthNodeError, safeAuthNodeErrorMessage, type AuthNodeErrorCode } from './errors.js';