@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.
@@ -0,0 +1,817 @@
1
+ /// <reference types="node" />
2
+
3
+ import { createHash, randomBytes } from 'node:crypto';
4
+
5
+ import { isAllowedWebScheme } from './api-origin.js';
6
+ import { NodeTokenCache, type CachedTokenProfile, type NodeTokenCacheOptions } from './cache.js';
7
+ import { AuthNodeError } from './errors.js';
8
+
9
+ const DEFAULT_SCOPES = ['openid', 'profile', 'offline_access'] as const;
10
+ const DEFAULT_DEVICE_INTERVAL_SECONDS = 5;
11
+ const SLOW_DOWN_INCREMENT_SECONDS = 5;
12
+ const DEFAULT_MAX_RESPONSE_BYTES = 64 * 1024;
13
+ const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
14
+ const DEFAULT_LOGIN_TIMEOUT_MS = 10 * 60 * 1000;
15
+ const DEFAULT_REFRESH_LEEWAY_SECONDS = 60;
16
+ const DISPLAY_CLAIMS_MAX_BYTES = 16 * 1024;
17
+ const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
18
+
19
+ export interface EndpointPolicy {
20
+ /** Additional issuer values accepted when discovery returns an alias. */
21
+ readonly issuerAllowlist?: readonly string[];
22
+ /** Additional origins allowed for discovered token and device endpoints. */
23
+ readonly endpointOriginAllowlist?: readonly string[];
24
+ /** Permits plain HTTP only for localhost and literal loopback addresses. */
25
+ readonly allowLoopbackHttp?: boolean;
26
+ }
27
+
28
+ export interface DeviceFlowClientConfig {
29
+ readonly issuer: string;
30
+ readonly clientId: string;
31
+ readonly scopes?: readonly string[];
32
+ readonly audience?: string;
33
+ readonly cache: Pick<NodeTokenCacheOptions, 'namespace'> &
34
+ Partial<Omit<NodeTokenCacheOptions, 'namespace'>>;
35
+ readonly profile?: string;
36
+ readonly endpointPolicy?: EndpointPolicy;
37
+ readonly maxResponseBytes?: number;
38
+ readonly requestTimeoutMs?: number;
39
+ readonly loginTimeoutMs?: number;
40
+ readonly refreshLeewaySeconds?: number;
41
+ }
42
+
43
+ export type DeviceFlowStatus = 'waiting' | 'slow-down' | 'authorized';
44
+
45
+ export interface DeviceVerification {
46
+ readonly verificationUri: string;
47
+ readonly verificationUriComplete?: string;
48
+ readonly userCode: string;
49
+ }
50
+
51
+ export interface DeviceFlowPresentation {
52
+ readonly showVerification?: (verification: DeviceVerification) => void | Promise<void>;
53
+ readonly showStatus?: (status: DeviceFlowStatus) => void | Promise<void>;
54
+ readonly openBrowser?: (url: string) => void | Promise<void>;
55
+ }
56
+
57
+ export interface DeviceFlowEnvironment {
58
+ readonly fetch?: typeof globalThis.fetch;
59
+ readonly now?: () => number;
60
+ readonly sleep?: (milliseconds: number, signal?: AbortSignal) => Promise<void>;
61
+ readonly signal?: AbortSignal;
62
+ readonly environmentToken?: () => string | undefined | Promise<string | undefined>;
63
+ }
64
+
65
+ export interface AccessTokenOptions {
66
+ readonly forceRefresh?: boolean;
67
+ readonly signal?: AbortSignal;
68
+ }
69
+
70
+ export interface LoginOptions {
71
+ readonly signal?: AbortSignal;
72
+ readonly presentation?: DeviceFlowPresentation;
73
+ }
74
+
75
+ export interface OidcDeviceMetadata {
76
+ readonly issuer: string;
77
+ readonly deviceAuthorizationEndpoint: string;
78
+ readonly tokenEndpoint: string;
79
+ }
80
+
81
+ export interface DisplayOnlyClaims {
82
+ readonly subject?: string;
83
+ readonly name?: string;
84
+ readonly preferredUsername?: string;
85
+ readonly email?: string;
86
+ readonly expiresAt?: number;
87
+ }
88
+
89
+ interface NormalizedConfig {
90
+ readonly issuer: string;
91
+ readonly clientId: string;
92
+ readonly scopes: readonly string[];
93
+ readonly audience?: string;
94
+ readonly profile: string;
95
+ readonly endpointPolicy: Required<EndpointPolicy>;
96
+ readonly maxResponseBytes: number;
97
+ readonly requestTimeoutMs: number;
98
+ readonly loginTimeoutMs: number;
99
+ readonly refreshLeewayMs: number;
100
+ }
101
+
102
+ interface DeviceAuthorization {
103
+ readonly deviceCode: string;
104
+ readonly userCode: string;
105
+ readonly verificationUri: string;
106
+ readonly verificationUriComplete?: string;
107
+ readonly expiresIn: number;
108
+ readonly interval: number;
109
+ readonly codeVerifier: string;
110
+ }
111
+
112
+ interface TokenResponse {
113
+ readonly accessToken: string;
114
+ readonly refreshToken?: string;
115
+ readonly idToken?: string;
116
+ readonly tokenType: string;
117
+ readonly expiresIn: number;
118
+ }
119
+
120
+ const refreshFlights = new Map<string, Promise<CachedTokenProfile>>();
121
+
122
+ /** Node OIDC device-flow client. It never writes to stdout, stderr, or a logger. */
123
+ export class DeviceFlowClient {
124
+ readonly #config: NormalizedConfig;
125
+ readonly #environment: Required<Pick<DeviceFlowEnvironment, 'fetch' | 'now' | 'sleep'>> &
126
+ Pick<DeviceFlowEnvironment, 'signal' | 'environmentToken'>;
127
+ readonly #cache: NodeTokenCache;
128
+ #metadataPromise: Promise<OidcDeviceMetadata> | undefined;
129
+
130
+ public constructor(config: DeviceFlowClientConfig, environment: DeviceFlowEnvironment = {}) {
131
+ this.#config = normalizeConfig(config);
132
+ this.#environment = {
133
+ fetch: environment.fetch ?? globalThis.fetch,
134
+ now: environment.now ?? Date.now,
135
+ sleep: environment.sleep ?? abortableDelay,
136
+ ...(environment.signal === undefined ? {} : { signal: environment.signal }),
137
+ ...(environment.environmentToken === undefined
138
+ ? {}
139
+ : { environmentToken: environment.environmentToken }),
140
+ };
141
+ this.#cache = new NodeTokenCache({
142
+ ...config.cache,
143
+ now: config.cache.now ?? this.#environment.now,
144
+ sleep: config.cache.sleep ?? this.#environment.sleep,
145
+ });
146
+ }
147
+
148
+ public discover(signal?: AbortSignal): Promise<OidcDeviceMetadata> {
149
+ const requestSignal = combineSignals(this.#environment.signal, signal);
150
+ this.#metadataPromise ??= discoverDeviceProvider(
151
+ this.#config.issuer,
152
+ this.#config.endpointPolicy,
153
+ this.#environment.fetch,
154
+ {
155
+ maxResponseBytes: this.#config.maxResponseBytes,
156
+ requestTimeoutMs: this.#config.requestTimeoutMs,
157
+ ...(requestSignal === undefined ? {} : { signal: requestSignal }),
158
+ },
159
+ ).catch((error: unknown) => {
160
+ this.#metadataPromise = undefined;
161
+ throw error;
162
+ });
163
+ return this.#metadataPromise;
164
+ }
165
+
166
+ public async login(options: LoginOptions = {}): Promise<CachedTokenProfile> {
167
+ const parentSignal = combineSignals(this.#environment.signal, options.signal);
168
+ const total = timeoutSignal(parentSignal, this.#config.loginTimeoutMs);
169
+ try {
170
+ const metadata = await this.discover(total.signal);
171
+ const device = await this.#authorizeDevice(metadata, total.signal);
172
+ const verification: DeviceVerification = {
173
+ verificationUri: device.verificationUri,
174
+ userCode: device.userCode,
175
+ ...(device.verificationUriComplete === undefined
176
+ ? {}
177
+ : { verificationUriComplete: device.verificationUriComplete }),
178
+ };
179
+ await options.presentation?.showVerification?.(verification);
180
+ const browserUri = device.verificationUriComplete ?? device.verificationUri;
181
+ await options.presentation?.openBrowser?.(browserUri);
182
+ const token = await this.#poll(metadata, device, total.signal, options.presentation);
183
+ const profile = this.#toCachedProfile(token);
184
+ await this.#cache.write(this.#config.profile, profile, total.signal);
185
+ return profile;
186
+ } catch (error) {
187
+ throw mapAbort(error, parentSignal, total.didTimeout());
188
+ } finally {
189
+ total.dispose();
190
+ }
191
+ }
192
+
193
+ public async accessToken(options: AccessTokenOptions = {}): Promise<string> {
194
+ const environmentToken = (await this.#environment.environmentToken?.())?.trim();
195
+ if (environmentToken) return environmentToken;
196
+ const signal = combineSignals(this.#environment.signal, options.signal);
197
+ throwIfAborted(signal);
198
+ const cached = await this.#cache.read(this.#config.profile);
199
+ if (cached === undefined || !this.#matchesConfig(cached)) {
200
+ throw new AuthNodeError('no_cached_session');
201
+ }
202
+ if (options.forceRefresh !== true && this.#isFresh(cached)) return cached.accessToken;
203
+ if (cached.refreshToken === undefined) throw new AuthNodeError('no_cached_session');
204
+
205
+ const flightKey = `${this.#cache.path}\0${this.#config.profile}`;
206
+ let flight = refreshFlights.get(flightKey);
207
+ if (flight === undefined) {
208
+ flight = this.#refreshUnderLock(cached, options.forceRefresh === true, signal);
209
+ refreshFlights.set(flightKey, flight);
210
+ const clearFlight = (): void => {
211
+ if (refreshFlights.get(flightKey) === flight) refreshFlights.delete(flightKey);
212
+ };
213
+ void flight.then(clearFlight, clearFlight);
214
+ }
215
+ return (await flight).accessToken;
216
+ }
217
+
218
+ public logout(signal?: AbortSignal): Promise<boolean> {
219
+ return this.#cache.remove(
220
+ this.#config.profile,
221
+ combineSignals(this.#environment.signal, signal),
222
+ );
223
+ }
224
+
225
+ /** Returns unverified claims for display text only. Never use them for authorization or identity keys. */
226
+ public async displayClaims(): Promise<DisplayOnlyClaims | undefined> {
227
+ const cached = await this.#cache.read(this.#config.profile);
228
+ if (cached === undefined) return undefined;
229
+ return decodeDisplayOnlyClaims(cached.idToken ?? cached.accessToken);
230
+ }
231
+
232
+ async #authorizeDevice(
233
+ metadata: OidcDeviceMetadata,
234
+ signal: AbortSignal | undefined,
235
+ ): Promise<DeviceAuthorization> {
236
+ const codeVerifier = randomBytes(32).toString('base64url');
237
+ const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
238
+ const response = await postForm(
239
+ metadata.deviceAuthorizationEndpoint,
240
+ {
241
+ client_id: this.#config.clientId,
242
+ scope: this.#config.scopes.join(' '),
243
+ code_challenge: codeChallenge,
244
+ code_challenge_method: 'S256',
245
+ ...(this.#config.audience === undefined ? {} : { audience: this.#config.audience }),
246
+ },
247
+ this.#environment.fetch,
248
+ this.#requestOptions(signal),
249
+ 'invalid_device_response',
250
+ 'device_authorization_failed',
251
+ );
252
+ if (!response.response.ok) {
253
+ throw new AuthNodeError('device_authorization_failed', { status: response.response.status });
254
+ }
255
+ const body = response.body;
256
+ const verificationUri = requiredString(body, 'verification_uri', 'invalid_device_response');
257
+ validateWebUrl(verificationUri, this.#config.endpointPolicy.allowLoopbackHttp);
258
+ const verificationUriComplete = optionalString(body, 'verification_uri_complete');
259
+ if (verificationUriComplete !== undefined) {
260
+ validateWebUrl(verificationUriComplete, this.#config.endpointPolicy.allowLoopbackHttp);
261
+ }
262
+ return {
263
+ deviceCode: requiredString(body, 'device_code', 'invalid_device_response'),
264
+ userCode: requiredString(body, 'user_code', 'invalid_device_response'),
265
+ verificationUri,
266
+ ...(verificationUriComplete === undefined ? {} : { verificationUriComplete }),
267
+ expiresIn: requiredPositiveInteger(body, 'expires_in', 'invalid_device_response'),
268
+ interval:
269
+ optionalPositiveInteger(body, 'interval', 'invalid_device_response') ??
270
+ DEFAULT_DEVICE_INTERVAL_SECONDS,
271
+ codeVerifier,
272
+ };
273
+ }
274
+
275
+ async #poll(
276
+ metadata: OidcDeviceMetadata,
277
+ device: DeviceAuthorization,
278
+ signal: AbortSignal | undefined,
279
+ presentation: DeviceFlowPresentation | undefined,
280
+ ): Promise<TokenResponse> {
281
+ const deviceDeadline = this.#environment.now() + device.expiresIn * 1000;
282
+ let intervalSeconds = device.interval;
283
+ await presentation?.showStatus?.('waiting');
284
+ while (this.#environment.now() < deviceDeadline) {
285
+ await this.#environment.sleep(intervalSeconds * 1000, signal);
286
+ throwIfAborted(signal);
287
+ if (this.#environment.now() >= deviceDeadline) break;
288
+ const result = await postForm(
289
+ metadata.tokenEndpoint,
290
+ {
291
+ grant_type: DEVICE_GRANT,
292
+ device_code: device.deviceCode,
293
+ client_id: this.#config.clientId,
294
+ code_verifier: device.codeVerifier,
295
+ },
296
+ this.#environment.fetch,
297
+ this.#requestOptions(signal),
298
+ 'invalid_token_response',
299
+ 'device_authorization_failed',
300
+ );
301
+ if (result.response.ok) {
302
+ await presentation?.showStatus?.('authorized');
303
+ return parseTokenResponse(result.body);
304
+ }
305
+ const oauthError = optionalString(result.body, 'error');
306
+ if (oauthError === 'authorization_pending') continue;
307
+ if (oauthError === 'slow_down') {
308
+ intervalSeconds += SLOW_DOWN_INCREMENT_SECONDS;
309
+ await presentation?.showStatus?.('slow-down');
310
+ continue;
311
+ }
312
+ if (oauthError === 'access_denied') throw new AuthNodeError('device_authorization_denied');
313
+ if (oauthError === 'expired_token') throw new AuthNodeError('device_authorization_expired');
314
+ throw new AuthNodeError('device_authorization_failed', { status: result.response.status });
315
+ }
316
+ throw new AuthNodeError('device_authorization_expired');
317
+ }
318
+
319
+ async #refreshUnderLock(
320
+ observed: CachedTokenProfile,
321
+ forceRefresh: boolean,
322
+ signal: AbortSignal | undefined,
323
+ ): Promise<CachedTokenProfile> {
324
+ return this.#cache.withLock(async (transaction) => {
325
+ const current = await transaction.read(this.#config.profile);
326
+ if (current === undefined || !this.#matchesConfig(current)) {
327
+ throw new AuthNodeError('no_cached_session');
328
+ }
329
+ if (
330
+ current.accessToken !== observed.accessToken ||
331
+ (!forceRefresh && this.#isFresh(current))
332
+ ) {
333
+ return current;
334
+ }
335
+ if (current.refreshToken === undefined) throw new AuthNodeError('no_cached_session');
336
+ const metadata = await this.discover(signal);
337
+ const result = await postForm(
338
+ metadata.tokenEndpoint,
339
+ {
340
+ grant_type: 'refresh_token',
341
+ refresh_token: current.refreshToken,
342
+ client_id: this.#config.clientId,
343
+ ...(this.#config.audience === undefined ? {} : { audience: this.#config.audience }),
344
+ },
345
+ this.#environment.fetch,
346
+ this.#requestOptions(signal),
347
+ 'invalid_token_response',
348
+ 'refresh_failed',
349
+ );
350
+ if (!result.response.ok) {
351
+ throw new AuthNodeError('refresh_failed', {
352
+ retryable: result.response.status >= 500,
353
+ status: result.response.status,
354
+ });
355
+ }
356
+ const token = parseTokenResponse(result.body);
357
+ const refreshed = this.#toCachedProfile(token, current.refreshToken);
358
+ await transaction.write(this.#config.profile, refreshed);
359
+ return refreshed;
360
+ }, signal);
361
+ }
362
+
363
+ #toCachedProfile(token: TokenResponse, previousRefreshToken?: string): CachedTokenProfile {
364
+ const refreshToken = token.refreshToken ?? previousRefreshToken;
365
+ return {
366
+ accessToken: token.accessToken,
367
+ ...(refreshToken === undefined ? {} : { refreshToken }),
368
+ ...(token.idToken === undefined ? {} : { idToken: token.idToken }),
369
+ tokenType: token.tokenType,
370
+ expiresAt: this.#environment.now() + token.expiresIn * 1000,
371
+ issuer: this.#config.issuer,
372
+ clientId: this.#config.clientId,
373
+ scopes: this.#config.scopes,
374
+ ...(this.#config.audience === undefined ? {} : { audience: this.#config.audience }),
375
+ };
376
+ }
377
+
378
+ #isFresh(profile: CachedTokenProfile): boolean {
379
+ return profile.expiresAt > this.#environment.now() + this.#config.refreshLeewayMs;
380
+ }
381
+
382
+ #matchesConfig(profile: CachedTokenProfile): boolean {
383
+ return (
384
+ profile.issuer === this.#config.issuer &&
385
+ profile.clientId === this.#config.clientId &&
386
+ profile.audience === this.#config.audience &&
387
+ profile.scopes.length === this.#config.scopes.length &&
388
+ profile.scopes.every((scope, index) => scope === this.#config.scopes[index])
389
+ );
390
+ }
391
+
392
+ #requestOptions(signal?: AbortSignal): DeviceProviderRequestOptions {
393
+ return {
394
+ maxResponseBytes: this.#config.maxResponseBytes,
395
+ requestTimeoutMs: this.#config.requestTimeoutMs,
396
+ ...(signal === undefined ? {} : { signal }),
397
+ };
398
+ }
399
+ }
400
+
401
+ export interface DeviceProviderRequestOptions {
402
+ readonly maxResponseBytes: number;
403
+ readonly requestTimeoutMs: number;
404
+ readonly signal?: AbortSignal;
405
+ }
406
+
407
+ export async function discoverDeviceProvider(
408
+ configuredIssuer: string,
409
+ policy: EndpointPolicy,
410
+ fetchImplementation: typeof globalThis.fetch,
411
+ options: DeviceProviderRequestOptions,
412
+ ): Promise<OidcDeviceMetadata> {
413
+ const normalizedPolicy = normalizePolicy(policy);
414
+ const requestOptions: DeviceProviderRequestOptions = {
415
+ maxResponseBytes: positiveInteger(options.maxResponseBytes, 'maximum response size'),
416
+ requestTimeoutMs: positiveInteger(options.requestTimeoutMs, 'request timeout'),
417
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
418
+ };
419
+ const issuer = normalizeWebUrl(configuredIssuer, normalizedPolicy.allowLoopbackHttp, 'issuer');
420
+ const discoveryUrl = `${issuer}/.well-known/openid-configuration`;
421
+ let result: JsonResponse;
422
+ try {
423
+ result = await requestJson(
424
+ discoveryUrl,
425
+ { headers: { Accept: 'application/json' } },
426
+ fetchImplementation,
427
+ requestOptions,
428
+ 'invalid_discovery_document',
429
+ 'discovery_failed',
430
+ );
431
+ } catch (error) {
432
+ if (error instanceof AuthNodeError) throw error;
433
+ throw new AuthNodeError('discovery_failed');
434
+ }
435
+ if (!result.response.ok) {
436
+ throw new AuthNodeError('discovery_failed', {
437
+ retryable: result.response.status >= 500,
438
+ status: result.response.status,
439
+ });
440
+ }
441
+ const discoveredIssuer = normalizeWebUrl(
442
+ requiredString(result.body, 'issuer', 'invalid_discovery_document'),
443
+ normalizedPolicy.allowLoopbackHttp,
444
+ 'issuer',
445
+ );
446
+ const allowedIssuers = new Set([issuer, ...normalizedPolicy.issuerAllowlist]);
447
+ if (!allowedIssuers.has(discoveredIssuer)) throw new AuthNodeError('issuer_mismatch');
448
+ const tokenEndpoint = validateDiscoveredEndpoint(
449
+ requiredString(result.body, 'token_endpoint', 'invalid_discovery_document'),
450
+ discoveredIssuer,
451
+ normalizedPolicy,
452
+ );
453
+ const deviceAuthorizationEndpoint = validateDiscoveredEndpoint(
454
+ requiredString(result.body, 'device_authorization_endpoint', 'invalid_discovery_document'),
455
+ discoveredIssuer,
456
+ normalizedPolicy,
457
+ );
458
+ return { issuer: discoveredIssuer, tokenEndpoint, deviceAuthorizationEndpoint };
459
+ }
460
+
461
+ /** Decodes a small allowlist of unverified JWT claims for display text only. */
462
+ export function decodeDisplayOnlyClaims(token: string): DisplayOnlyClaims | undefined {
463
+ try {
464
+ const payload = token.split('.')[1];
465
+ if (payload === undefined || Buffer.byteLength(payload) > DISPLAY_CLAIMS_MAX_BYTES)
466
+ return undefined;
467
+ const decoded = Buffer.from(payload, 'base64url').toString('utf8');
468
+ if (Buffer.byteLength(decoded) > DISPLAY_CLAIMS_MAX_BYTES) return undefined;
469
+ const value = JSON.parse(decoded) as unknown;
470
+ if (!isRecord(value)) return undefined;
471
+ const subject = optionalString(value, 'sub');
472
+ const name = optionalString(value, 'name');
473
+ const preferredUsername = optionalString(value, 'preferred_username');
474
+ const email = optionalString(value, 'email');
475
+ const expiresAt = optionalPositiveInteger(value, 'exp', 'invalid_token_response');
476
+ return {
477
+ ...(subject === undefined ? {} : { subject }),
478
+ ...(name === undefined ? {} : { name }),
479
+ ...(preferredUsername === undefined ? {} : { preferredUsername }),
480
+ ...(email === undefined ? {} : { email }),
481
+ ...(expiresAt === undefined ? {} : { expiresAt }),
482
+ };
483
+ } catch {
484
+ return undefined;
485
+ }
486
+ }
487
+
488
+ interface JsonResponse {
489
+ readonly response: Response;
490
+ readonly body: Record<string, unknown>;
491
+ }
492
+
493
+ async function postForm(
494
+ endpoint: string,
495
+ fields: Record<string, string>,
496
+ fetchImplementation: typeof globalThis.fetch,
497
+ options: DeviceProviderRequestOptions,
498
+ invalidResponseCode:
499
+ 'invalid_device_response' | 'invalid_discovery_document' | 'invalid_token_response',
500
+ requestFailureCode: 'device_authorization_failed' | 'discovery_failed' | 'refresh_failed',
501
+ ): Promise<JsonResponse> {
502
+ return requestJson(
503
+ endpoint,
504
+ {
505
+ method: 'POST',
506
+ headers: {
507
+ Accept: 'application/json',
508
+ 'Content-Type': 'application/x-www-form-urlencoded',
509
+ },
510
+ body: new URLSearchParams(fields),
511
+ },
512
+ fetchImplementation,
513
+ options,
514
+ invalidResponseCode,
515
+ requestFailureCode,
516
+ );
517
+ }
518
+
519
+ async function requestJson(
520
+ input: string,
521
+ init: RequestInit,
522
+ fetchImplementation: typeof globalThis.fetch,
523
+ options: DeviceProviderRequestOptions,
524
+ invalidResponseCode:
525
+ 'invalid_device_response' | 'invalid_discovery_document' | 'invalid_token_response',
526
+ requestFailureCode: 'device_authorization_failed' | 'discovery_failed' | 'refresh_failed',
527
+ ): Promise<JsonResponse> {
528
+ const requestTimeout = timeoutSignal(options.signal, options.requestTimeoutMs);
529
+ try {
530
+ const response = await fetchImplementation(input, { ...init, signal: requestTimeout.signal });
531
+ const declaredLength = Number(response.headers.get('content-length'));
532
+ if (Number.isFinite(declaredLength) && declaredLength > options.maxResponseBytes) {
533
+ throw new AuthNodeError(invalidResponseCode);
534
+ }
535
+ let text = '';
536
+ if (response.body !== null) {
537
+ const reader = response.body.getReader();
538
+ const decoder = new TextDecoder();
539
+ let bytes = 0;
540
+ for (;;) {
541
+ const chunk = await reader.read();
542
+ if (chunk.done) break;
543
+ bytes += chunk.value.byteLength;
544
+ if (bytes > options.maxResponseBytes) {
545
+ await reader.cancel().catch(() => undefined);
546
+ throw new AuthNodeError(invalidResponseCode);
547
+ }
548
+ text += decoder.decode(chunk.value, { stream: true });
549
+ }
550
+ text += decoder.decode();
551
+ }
552
+ let value: unknown;
553
+ try {
554
+ value = JSON.parse(text) as unknown;
555
+ } catch {
556
+ throw new AuthNodeError(invalidResponseCode);
557
+ }
558
+ if (!isRecord(value)) throw new AuthNodeError(invalidResponseCode);
559
+ return { response, body: value };
560
+ } catch (error) {
561
+ const mapped = mapAbort(error, options.signal, requestTimeout.didTimeout());
562
+ if (mapped instanceof AuthNodeError) throw mapped;
563
+ throw new AuthNodeError(requestFailureCode, { retryable: true });
564
+ } finally {
565
+ requestTimeout.dispose();
566
+ }
567
+ }
568
+
569
+ function parseTokenResponse(body: Record<string, unknown>): TokenResponse {
570
+ const refreshToken = optionalString(body, 'refresh_token');
571
+ const idToken = optionalString(body, 'id_token');
572
+ return {
573
+ accessToken: requiredString(body, 'access_token', 'invalid_token_response'),
574
+ ...(refreshToken === undefined ? {} : { refreshToken }),
575
+ ...(idToken === undefined ? {} : { idToken }),
576
+ tokenType: optionalString(body, 'token_type') ?? 'Bearer',
577
+ expiresIn: requiredPositiveInteger(body, 'expires_in', 'invalid_token_response'),
578
+ };
579
+ }
580
+
581
+ function normalizeConfig(config: DeviceFlowClientConfig): NormalizedConfig {
582
+ const policy = normalizePolicy(config.endpointPolicy ?? {});
583
+ const issuer = normalizeWebUrl(config.issuer, policy.allowLoopbackHttp, 'issuer');
584
+ const clientId = requiredText(config.clientId, 'OIDC client ID');
585
+ const scopes = normalizeScopes(config.scopes);
586
+ return {
587
+ issuer,
588
+ clientId,
589
+ scopes,
590
+ ...(config.audience === undefined
591
+ ? {}
592
+ : { audience: requiredText(config.audience, 'audience') }),
593
+ profile: config.profile ?? 'default',
594
+ endpointPolicy: policy,
595
+ maxResponseBytes: positiveInteger(
596
+ config.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
597
+ 'maximum response size',
598
+ ),
599
+ requestTimeoutMs: positiveInteger(
600
+ config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
601
+ 'request timeout',
602
+ ),
603
+ loginTimeoutMs: positiveInteger(
604
+ config.loginTimeoutMs ?? DEFAULT_LOGIN_TIMEOUT_MS,
605
+ 'login timeout',
606
+ ),
607
+ refreshLeewayMs:
608
+ nonNegativeInteger(
609
+ config.refreshLeewaySeconds ?? DEFAULT_REFRESH_LEEWAY_SECONDS,
610
+ 'refresh leeway',
611
+ ) * 1000,
612
+ };
613
+ }
614
+
615
+ function normalizePolicy(policy: EndpointPolicy): Required<EndpointPolicy> {
616
+ const allowLoopbackHttp = policy.allowLoopbackHttp ?? false;
617
+ return {
618
+ allowLoopbackHttp,
619
+ issuerAllowlist: (policy.issuerAllowlist ?? []).map((issuer) =>
620
+ normalizeWebUrl(issuer, allowLoopbackHttp, 'issuer'),
621
+ ),
622
+ endpointOriginAllowlist: (policy.endpointOriginAllowlist ?? []).map((origin) => {
623
+ const url = parseUrl(origin, 'config');
624
+ if (url.origin !== url.toString().replace(/\/$/u, '') || url.username || url.password) {
625
+ throw new TypeError('endpoint origin allowlist entries must contain only an origin.');
626
+ }
627
+ validateWebUrl(url.toString(), allowLoopbackHttp);
628
+ return url.origin;
629
+ }),
630
+ };
631
+ }
632
+
633
+ function validateDiscoveredEndpoint(
634
+ value: string,
635
+ issuer: string,
636
+ policy: Required<EndpointPolicy>,
637
+ ): string {
638
+ const endpoint = parseUrl(value, 'provider');
639
+ validateWebUrl(endpoint.toString(), policy.allowLoopbackHttp);
640
+ if (endpoint.username || endpoint.password || endpoint.hash) {
641
+ throw new AuthNodeError('endpoint_policy_violation');
642
+ }
643
+ const issuerUrl = parseUrl(issuer, 'provider');
644
+ const originAllowed =
645
+ endpoint.origin === issuerUrl.origin ||
646
+ policy.endpointOriginAllowlist.includes(endpoint.origin);
647
+ if (!originAllowed) throw new AuthNodeError('endpoint_policy_violation');
648
+ if (endpoint.origin === issuerUrl.origin) {
649
+ const issuerPath = issuerUrl.pathname.replace(/\/$/u, '');
650
+ const relatedPath =
651
+ issuerPath.length === 0 ||
652
+ endpoint.pathname === issuerPath ||
653
+ endpoint.pathname.startsWith(`${issuerPath}/`);
654
+ if (!relatedPath) throw new AuthNodeError('endpoint_policy_violation');
655
+ }
656
+ return endpoint.toString();
657
+ }
658
+
659
+ function normalizeWebUrl(value: string, allowLoopbackHttp: boolean, label: string): string {
660
+ const url = parseUrl(requiredText(value, `OIDC ${label}`), 'config');
661
+ if (url.search || url.hash || url.username || url.password) {
662
+ throw new TypeError(`OIDC ${label} must not contain credentials, a query, or a fragment.`);
663
+ }
664
+ validateWebUrl(url.toString(), allowLoopbackHttp);
665
+ return url.toString().replace(/\/+$/u, '');
666
+ }
667
+
668
+ function validateWebUrl(value: string, allowLoopbackHttp: boolean): void {
669
+ if (!isAllowedWebScheme(parseUrl(value, 'provider'), allowLoopbackHttp)) {
670
+ throw new AuthNodeError('endpoint_policy_violation');
671
+ }
672
+ }
673
+
674
+ function parseUrl(value: string, source: 'config' | 'provider'): URL {
675
+ try {
676
+ return new URL(value);
677
+ } catch {
678
+ if (source === 'config') throw new TypeError('OIDC URL is invalid.');
679
+ throw new AuthNodeError('endpoint_policy_violation');
680
+ }
681
+ }
682
+
683
+ function normalizeScopes(scopes: readonly string[] | undefined): readonly string[] {
684
+ const values = scopes ?? DEFAULT_SCOPES;
685
+ const normalized = values.map((scope) => requiredText(scope, 'OIDC scope'));
686
+ if (!normalized.includes('openid')) normalized.unshift('openid');
687
+ return [...new Set(normalized)];
688
+ }
689
+
690
+ function requiredString(
691
+ value: Record<string, unknown>,
692
+ key: string,
693
+ code: 'invalid_device_response' | 'invalid_discovery_document' | 'invalid_token_response',
694
+ ): string {
695
+ const result = optionalString(value, key);
696
+ if (result === undefined) throw new AuthNodeError(code);
697
+ return result;
698
+ }
699
+
700
+ function optionalString(value: Record<string, unknown>, key: string): string | undefined {
701
+ const result = value[key];
702
+ return typeof result === 'string' && result.length > 0 ? result : undefined;
703
+ }
704
+
705
+ function requiredPositiveInteger(
706
+ value: Record<string, unknown>,
707
+ key: string,
708
+ code: 'invalid_device_response' | 'invalid_token_response',
709
+ ): number {
710
+ const result = optionalPositiveInteger(value, key, code);
711
+ if (result === undefined) throw new AuthNodeError(code);
712
+ return result;
713
+ }
714
+
715
+ function optionalPositiveInteger(
716
+ value: Record<string, unknown>,
717
+ key: string,
718
+ code: 'invalid_device_response' | 'invalid_token_response',
719
+ ): number | undefined {
720
+ const result = value[key];
721
+ if (result === undefined) return undefined;
722
+ if (!Number.isSafeInteger(result) || (result as number) <= 0) throw new AuthNodeError(code);
723
+ return result as number;
724
+ }
725
+
726
+ function requiredText(value: string, label: string): string {
727
+ const trimmed = value.trim();
728
+ if (trimmed.length === 0) throw new TypeError(`${label} must not be empty.`);
729
+ return trimmed;
730
+ }
731
+
732
+ function positiveInteger(value: number, label: string): number {
733
+ if (!Number.isSafeInteger(value) || value <= 0)
734
+ throw new RangeError(`${label} must be positive.`);
735
+ return value;
736
+ }
737
+
738
+ function nonNegativeInteger(value: number, label: string): number {
739
+ if (!Number.isSafeInteger(value) || value < 0) {
740
+ throw new RangeError(`${label} must be a non-negative integer.`);
741
+ }
742
+ return value;
743
+ }
744
+
745
+ function isRecord(value: unknown): value is Record<string, unknown> {
746
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
747
+ }
748
+
749
+ function throwIfAborted(signal?: AbortSignal): void {
750
+ if (signal?.aborted === true) throw new AuthNodeError('aborted');
751
+ }
752
+
753
+ function mapAbort(
754
+ error: unknown,
755
+ parentSignal: AbortSignal | undefined,
756
+ timedOut: boolean,
757
+ ): unknown {
758
+ if (parentSignal?.aborted === true) return new AuthNodeError('aborted');
759
+ if (timedOut) return new AuthNodeError('login_timeout', { retryable: true });
760
+ return error;
761
+ }
762
+
763
+ function combineSignals(
764
+ first: AbortSignal | undefined,
765
+ second: AbortSignal | undefined,
766
+ ): AbortSignal | undefined {
767
+ if (first === undefined) return second;
768
+ if (second === undefined) return first;
769
+ return AbortSignal.any([first, second]);
770
+ }
771
+
772
+ function timeoutSignal(
773
+ parent: AbortSignal | undefined,
774
+ milliseconds: number,
775
+ ): {
776
+ readonly signal: AbortSignal;
777
+ readonly didTimeout: () => boolean;
778
+ readonly dispose: () => void;
779
+ } {
780
+ const controller = new AbortController();
781
+ let timedOut = false;
782
+ const timeout = setTimeout(() => {
783
+ timedOut = true;
784
+ controller.abort();
785
+ }, milliseconds);
786
+ const abortFromParent = (): void => {
787
+ controller.abort();
788
+ };
789
+ parent?.addEventListener('abort', abortFromParent, { once: true });
790
+ if (parent?.aborted === true) controller.abort();
791
+ return {
792
+ signal: controller.signal,
793
+ didTimeout: () => timedOut,
794
+ dispose: () => {
795
+ clearTimeout(timeout);
796
+ parent?.removeEventListener('abort', abortFromParent);
797
+ },
798
+ };
799
+ }
800
+
801
+ function abortableDelay(milliseconds: number, signal?: AbortSignal): Promise<void> {
802
+ return new Promise((resolveDelay, reject) => {
803
+ if (signal?.aborted === true) {
804
+ reject(new AuthNodeError('aborted'));
805
+ return;
806
+ }
807
+ const onAbort = (): void => {
808
+ clearTimeout(timeout);
809
+ reject(new AuthNodeError('aborted'));
810
+ };
811
+ const timeout = setTimeout(() => {
812
+ signal?.removeEventListener('abort', onAbort);
813
+ resolveDelay();
814
+ }, milliseconds);
815
+ signal?.addEventListener('abort', onAbort, { once: true });
816
+ });
817
+ }