@better-auth/sso 1.4.7-beta.3 → 1.4.7

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,1157 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ computeDiscoveryUrl,
4
+ discoverOIDCConfig,
5
+ fetchDiscoveryDocument,
6
+ needsRuntimeDiscovery,
7
+ normalizeDiscoveryUrls,
8
+ normalizeUrl,
9
+ selectTokenEndpointAuthMethod,
10
+ validateDiscoveryDocument,
11
+ validateDiscoveryUrl,
12
+ } from "./discovery";
13
+ import type { OIDCDiscoveryDocument } from "./types";
14
+ import { DiscoveryError } from "./types";
15
+
16
+ vi.mock("@better-fetch/fetch", () => ({
17
+ betterFetch: vi.fn(),
18
+ }));
19
+
20
+ import { betterFetch } from "@better-fetch/fetch";
21
+
22
+ /**
23
+ * Mock OIDC Discovery Document
24
+ * Represents a valid discovery response from an IdP
25
+ */
26
+ const createMockDiscoveryDocument = (
27
+ overrides: Partial<OIDCDiscoveryDocument> = {},
28
+ ): OIDCDiscoveryDocument => ({
29
+ issuer: "https://idp.example.com",
30
+ authorization_endpoint: "https://idp.example.com/oauth2/authorize",
31
+ token_endpoint: "https://idp.example.com/oauth2/token",
32
+ jwks_uri: "https://idp.example.com/.well-known/jwks.json",
33
+ userinfo_endpoint: "https://idp.example.com/userinfo",
34
+ token_endpoint_auth_methods_supported: [
35
+ "client_secret_basic",
36
+ "client_secret_post",
37
+ ],
38
+ scopes_supported: ["openid", "profile", "email", "offline_access"],
39
+ response_types_supported: ["code", "token", "id_token"],
40
+ subject_types_supported: ["public"],
41
+ id_token_signing_alg_values_supported: ["RS256"],
42
+ claims_supported: ["sub", "name", "email", "email_verified"],
43
+ ...overrides,
44
+ });
45
+
46
+ describe("OIDC Discovery", () => {
47
+ describe("computeDiscoveryUrl", () => {
48
+ it("should compute discovery URL from issuer without trailing slash", () => {
49
+ const url = computeDiscoveryUrl("https://idp.example.com");
50
+ expect(url).toBe(
51
+ "https://idp.example.com/.well-known/openid-configuration",
52
+ );
53
+ });
54
+
55
+ it("should compute discovery URL from issuer with trailing slash", () => {
56
+ const url = computeDiscoveryUrl("https://idp.example.com/");
57
+ expect(url).toBe(
58
+ "https://idp.example.com/.well-known/openid-configuration",
59
+ );
60
+ });
61
+
62
+ it("should handle issuer with path", () => {
63
+ const url = computeDiscoveryUrl("https://idp.example.com/tenant/v1");
64
+ expect(url).toBe(
65
+ "https://idp.example.com/tenant/v1/.well-known/openid-configuration",
66
+ );
67
+ });
68
+
69
+ it("should handle issuer with path and trailing slash", () => {
70
+ const url = computeDiscoveryUrl("https://idp.example.com/tenant/v1/");
71
+ expect(url).toBe(
72
+ "https://idp.example.com/tenant/v1/.well-known/openid-configuration",
73
+ );
74
+ });
75
+ });
76
+
77
+ describe("validateDiscoveryUrl", () => {
78
+ const isTrustedOrigin = vi.fn().mockReturnValue(true);
79
+
80
+ it("should accept valid HTTPS URL", () => {
81
+ expect(() =>
82
+ validateDiscoveryUrl(
83
+ "https://idp.example.com/.well-known/openid-configuration",
84
+ isTrustedOrigin,
85
+ ),
86
+ ).not.toThrow();
87
+ });
88
+
89
+ it("should accept valid HTTP URL", () => {
90
+ expect(() =>
91
+ validateDiscoveryUrl(
92
+ "http://localhost:8080/.well-known/openid-configuration",
93
+ isTrustedOrigin,
94
+ ),
95
+ ).not.toThrow();
96
+ });
97
+
98
+ it("should reject invalid URL", () => {
99
+ expect(() => validateDiscoveryUrl("not-a-url", isTrustedOrigin)).toThrow(
100
+ DiscoveryError,
101
+ );
102
+ expect(() => validateDiscoveryUrl("not-a-url", isTrustedOrigin)).toThrow(
103
+ 'The url "discoveryEndpoint" must be valid',
104
+ );
105
+ });
106
+
107
+ it("should reject non-HTTP protocols", () => {
108
+ expect(() =>
109
+ validateDiscoveryUrl("ftp://example.com/config", isTrustedOrigin),
110
+ ).toThrow(DiscoveryError);
111
+ expect(() =>
112
+ validateDiscoveryUrl("ftp://example.com/config", isTrustedOrigin),
113
+ ).toThrow("must use the http or https supported protocols");
114
+ });
115
+
116
+ it("should throw DiscoveryError with discovery_invalid_url code for invalid URL", () => {
117
+ expect(() => validateDiscoveryUrl("not-a-url", isTrustedOrigin)).toThrow(
118
+ expect.objectContaining({
119
+ code: "discovery_invalid_url",
120
+ details: expect.objectContaining({
121
+ url: "not-a-url",
122
+ }),
123
+ }),
124
+ );
125
+ });
126
+
127
+ it("should throw DiscoveryError with discovery_invalid_url code for non-HTTP protocol", () => {
128
+ expect(() =>
129
+ validateDiscoveryUrl("ftp://example.com/config", isTrustedOrigin),
130
+ ).toThrow(
131
+ expect.objectContaining({
132
+ code: "discovery_invalid_url",
133
+ details: expect.objectContaining({
134
+ protocol: "ftp:",
135
+ }),
136
+ }),
137
+ );
138
+ });
139
+
140
+ it("should throw DiscoveryError with discovery_untrusted_origin code for untrusted origins", () => {
141
+ isTrustedOrigin.mockReturnValue(false);
142
+
143
+ expect(() =>
144
+ validateDiscoveryUrl(
145
+ "https://untrusted.com/.well-known/openid-configuration",
146
+ isTrustedOrigin,
147
+ ),
148
+ ).toThrow(
149
+ expect.objectContaining({
150
+ code: "discovery_untrusted_origin",
151
+ message: `The main discovery endpoint "https://untrusted.com/.well-known/openid-configuration" is not trusted by your trusted origins configuration.`,
152
+ }),
153
+ );
154
+ });
155
+ });
156
+
157
+ describe("validateDiscoveryDocument", () => {
158
+ const issuer = "https://idp.example.com";
159
+
160
+ it("should accept valid discovery document", () => {
161
+ const doc = createMockDiscoveryDocument();
162
+ expect(() => validateDiscoveryDocument(doc, issuer)).not.toThrow();
163
+ });
164
+
165
+ it("should accept discovery document with only required fields", () => {
166
+ // This locks in the contract: only issuer, authorization_endpoint,
167
+ // token_endpoint, and jwks_uri are required. Everything else is optional.
168
+ const doc = {
169
+ issuer,
170
+ authorization_endpoint: `${issuer}/authorize`,
171
+ token_endpoint: `${issuer}/token`,
172
+ jwks_uri: `${issuer}/jwks`,
173
+ } as OIDCDiscoveryDocument;
174
+
175
+ expect(() => validateDiscoveryDocument(doc, issuer)).not.toThrow();
176
+ });
177
+
178
+ it("should throw discovery_incomplete for missing issuer", () => {
179
+ const doc = createMockDiscoveryDocument({ issuer: "" });
180
+ expect(() => validateDiscoveryDocument(doc, issuer)).toThrow(
181
+ expect.objectContaining({
182
+ code: "discovery_incomplete",
183
+ details: expect.objectContaining({
184
+ missingFields: expect.arrayContaining(["issuer"]),
185
+ }),
186
+ }),
187
+ );
188
+ });
189
+
190
+ it("should throw discovery_incomplete for missing authorization_endpoint", () => {
191
+ const doc = createMockDiscoveryDocument({ authorization_endpoint: "" });
192
+ expect(() => validateDiscoveryDocument(doc, issuer)).toThrow(
193
+ expect.objectContaining({
194
+ code: "discovery_incomplete",
195
+ details: expect.objectContaining({
196
+ missingFields: expect.arrayContaining(["authorization_endpoint"]),
197
+ }),
198
+ }),
199
+ );
200
+ });
201
+
202
+ it("should throw discovery_incomplete for missing token_endpoint", () => {
203
+ const doc = createMockDiscoveryDocument({ token_endpoint: "" });
204
+ expect(() => validateDiscoveryDocument(doc, issuer)).toThrow(
205
+ expect.objectContaining({
206
+ code: "discovery_incomplete",
207
+ details: expect.objectContaining({
208
+ missingFields: expect.arrayContaining(["token_endpoint"]),
209
+ }),
210
+ }),
211
+ );
212
+ });
213
+
214
+ it("should throw discovery_incomplete for missing jwks_uri", () => {
215
+ const doc = createMockDiscoveryDocument({ jwks_uri: "" });
216
+ expect(() => validateDiscoveryDocument(doc, issuer)).toThrow(
217
+ expect.objectContaining({
218
+ code: "discovery_incomplete",
219
+ details: expect.objectContaining({
220
+ missingFields: expect.arrayContaining(["jwks_uri"]),
221
+ }),
222
+ }),
223
+ );
224
+ });
225
+
226
+ it("should list all missing fields", () => {
227
+ const doc = {
228
+ issuer: "",
229
+ authorization_endpoint: "",
230
+ token_endpoint: "",
231
+ jwks_uri: "",
232
+ } as OIDCDiscoveryDocument;
233
+ expect(() => validateDiscoveryDocument(doc, issuer)).toThrow(
234
+ expect.objectContaining({
235
+ code: "discovery_incomplete",
236
+ details: expect.objectContaining({
237
+ missingFields: expect.arrayContaining([
238
+ "issuer",
239
+ "authorization_endpoint",
240
+ "token_endpoint",
241
+ "jwks_uri",
242
+ ]),
243
+ }),
244
+ }),
245
+ );
246
+ });
247
+
248
+ it("should throw issuer_mismatch when issuer doesn't match", () => {
249
+ const doc = createMockDiscoveryDocument({
250
+ issuer: "https://evil.example.com",
251
+ });
252
+ expect(() => validateDiscoveryDocument(doc, issuer)).toThrow(
253
+ expect.objectContaining({
254
+ code: "issuer_mismatch",
255
+ details: expect.objectContaining({
256
+ discovered: "https://evil.example.com",
257
+ configured: issuer,
258
+ }),
259
+ }),
260
+ );
261
+ });
262
+
263
+ it("should handle trailing slash normalization in issuer comparison", () => {
264
+ const doc = createMockDiscoveryDocument({
265
+ issuer: "https://idp.example.com/",
266
+ });
267
+ // Should NOT throw - trailing slash difference is normalized
268
+ expect(() =>
269
+ validateDiscoveryDocument(doc, "https://idp.example.com"),
270
+ ).not.toThrow();
271
+ });
272
+
273
+ it("should handle trailing slash in configured issuer", () => {
274
+ const doc = createMockDiscoveryDocument({
275
+ issuer: "https://idp.example.com",
276
+ });
277
+ // Should NOT throw - trailing slash difference is normalized
278
+ expect(() =>
279
+ validateDiscoveryDocument(doc, "https://idp.example.com/"),
280
+ ).not.toThrow();
281
+ });
282
+ });
283
+
284
+ describe("selectTokenEndpointAuthMethod", () => {
285
+ it("should return existing config value if provided", () => {
286
+ const doc = createMockDiscoveryDocument();
287
+ expect(selectTokenEndpointAuthMethod(doc, "client_secret_post")).toBe(
288
+ "client_secret_post",
289
+ );
290
+ });
291
+
292
+ it("should prefer client_secret_basic when both are supported", () => {
293
+ const doc = createMockDiscoveryDocument({
294
+ token_endpoint_auth_methods_supported: [
295
+ "client_secret_post",
296
+ "client_secret_basic",
297
+ ],
298
+ });
299
+ expect(selectTokenEndpointAuthMethod(doc)).toBe("client_secret_basic");
300
+ });
301
+
302
+ it("should use client_secret_post if only that is supported", () => {
303
+ const doc = createMockDiscoveryDocument({
304
+ token_endpoint_auth_methods_supported: ["client_secret_post"],
305
+ });
306
+ expect(selectTokenEndpointAuthMethod(doc)).toBe("client_secret_post");
307
+ });
308
+
309
+ it("should default to client_secret_basic when only unsupported methods are advertised", () => {
310
+ const doc = createMockDiscoveryDocument({
311
+ token_endpoint_auth_methods_supported: ["private_key_jwt"],
312
+ });
313
+ expect(selectTokenEndpointAuthMethod(doc)).toBe("client_secret_basic");
314
+ });
315
+
316
+ it("should default to client_secret_basic for tls_client_auth only", () => {
317
+ const doc = createMockDiscoveryDocument({
318
+ token_endpoint_auth_methods_supported: [
319
+ "tls_client_auth",
320
+ "private_key_jwt",
321
+ ],
322
+ });
323
+ expect(selectTokenEndpointAuthMethod(doc)).toBe("client_secret_basic");
324
+ });
325
+
326
+ it("should default to client_secret_basic if not specified in discovery", () => {
327
+ const doc = createMockDiscoveryDocument({
328
+ token_endpoint_auth_methods_supported: undefined,
329
+ });
330
+ expect(selectTokenEndpointAuthMethod(doc)).toBe("client_secret_basic");
331
+ });
332
+
333
+ it("should default to client_secret_basic for empty array", () => {
334
+ const doc = createMockDiscoveryDocument({
335
+ token_endpoint_auth_methods_supported: [],
336
+ });
337
+ expect(selectTokenEndpointAuthMethod(doc)).toBe("client_secret_basic");
338
+ });
339
+ });
340
+
341
+ describe("normalizeDiscoveryUrls", () => {
342
+ const isTrustedOrigin = vi.fn().mockReturnValue(true);
343
+
344
+ it("should return the document unchanged if all urls are already absolute", () => {
345
+ const doc = createMockDiscoveryDocument();
346
+ const result = normalizeDiscoveryUrls(
347
+ doc,
348
+ "https://idp.example.com",
349
+ isTrustedOrigin,
350
+ );
351
+ expect(result).toEqual(doc);
352
+ });
353
+
354
+ it("should resolve all required discovery urls relative to the issuer", () => {
355
+ const expected = createMockDiscoveryDocument({
356
+ issuer: "https://idp.example.com",
357
+ authorization_endpoint: "https://idp.example.com/oauth2/authorize",
358
+ token_endpoint: "https://idp.example.com/oauth2/token",
359
+ jwks_uri: "https://idp.example.com/.well-known/jwks.json",
360
+ });
361
+ const doc = createMockDiscoveryDocument({
362
+ issuer: "https://idp.example.com",
363
+ authorization_endpoint: "/oauth2/authorize",
364
+ token_endpoint: "/oauth2/token",
365
+ jwks_uri: "/.well-known/jwks.json",
366
+ });
367
+ const result = normalizeDiscoveryUrls(
368
+ doc,
369
+ "https://idp.example.com",
370
+ isTrustedOrigin,
371
+ );
372
+ expect(result).toEqual(expected);
373
+ });
374
+
375
+ it("should resolve all discovery urls relative to the issuer", () => {
376
+ const expected = createMockDiscoveryDocument({
377
+ issuer: "https://idp.example.com",
378
+ authorization_endpoint: "https://idp.example.com/oauth2/authorize",
379
+ token_endpoint: "https://idp.example.com/oauth2/token",
380
+ jwks_uri: "https://idp.example.com/.well-known/jwks.json",
381
+ userinfo_endpoint: "https://idp.example.com/userinfo",
382
+ revocation_endpoint: "https://idp.example.com/revoke",
383
+ });
384
+ const doc = createMockDiscoveryDocument({
385
+ issuer: "https://idp.example.com",
386
+ authorization_endpoint: "/oauth2/authorize",
387
+ token_endpoint: "/oauth2/token",
388
+ jwks_uri: "/.well-known/jwks.json",
389
+ userinfo_endpoint: "/userinfo",
390
+ revocation_endpoint: "/revoke",
391
+ });
392
+ const result = normalizeDiscoveryUrls(
393
+ doc,
394
+ "https://idp.example.com",
395
+ isTrustedOrigin,
396
+ );
397
+ expect(result).toEqual(expected);
398
+ });
399
+
400
+ it("should reject on invalid discovery urls", () => {
401
+ const doc = createMockDiscoveryDocument({
402
+ authorization_endpoint: "/oauth2/authorize",
403
+ });
404
+ expect(() =>
405
+ normalizeDiscoveryUrls(doc, "not-url", isTrustedOrigin),
406
+ ).toThrowError('The url "authorization_endpoint" must be valid');
407
+ });
408
+
409
+ it("should reject with discovery_untrusted_origin code on untrusted discovery urls", () => {
410
+ const doc = createMockDiscoveryDocument({
411
+ authorization_endpoint: "/oauth2/authorize",
412
+ token_endpoint: "/oauth2/token",
413
+ jwks_uri: "/.well-known/jwks.json",
414
+ userinfo_endpoint: "/userinfo",
415
+ revocation_endpoint: "/revoke",
416
+ end_session_endpoint: "/endsession",
417
+ introspection_endpoint: "/introspection",
418
+ });
419
+
420
+ expect(() =>
421
+ normalizeDiscoveryUrls(
422
+ doc,
423
+ "https://idp.example.com",
424
+ (url) => !url.endsWith("/oauth2/token"),
425
+ ),
426
+ ).toThrowError(
427
+ expect.objectContaining({
428
+ code: "discovery_untrusted_origin",
429
+ message:
430
+ 'The token_endpoint "https://idp.example.com/oauth2/token" is not trusted by your trusted origins configuration.',
431
+ details: {
432
+ endpoint: "token_endpoint",
433
+ url: "https://idp.example.com/oauth2/token",
434
+ },
435
+ }),
436
+ );
437
+
438
+ expect(() =>
439
+ normalizeDiscoveryUrls(
440
+ doc,
441
+ "https://idp.example.com",
442
+ (url) => !url.endsWith("/oauth2/authorize"),
443
+ ),
444
+ ).toThrowError(
445
+ expect.objectContaining({
446
+ code: "discovery_untrusted_origin",
447
+ message:
448
+ 'The authorization_endpoint "https://idp.example.com/oauth2/authorize" is not trusted by your trusted origins configuration.',
449
+ details: {
450
+ endpoint: "authorization_endpoint",
451
+ url: "https://idp.example.com/oauth2/authorize",
452
+ },
453
+ }),
454
+ );
455
+
456
+ expect(() =>
457
+ normalizeDiscoveryUrls(
458
+ doc,
459
+ "https://idp.example.com",
460
+ (url) => !url.endsWith("/.well-known/jwks.json"),
461
+ ),
462
+ ).toThrowError(
463
+ expect.objectContaining({
464
+ code: "discovery_untrusted_origin",
465
+ message:
466
+ 'The jwks_uri "https://idp.example.com/.well-known/jwks.json" is not trusted by your trusted origins configuration.',
467
+ details: {
468
+ endpoint: "jwks_uri",
469
+ url: "https://idp.example.com/.well-known/jwks.json",
470
+ },
471
+ }),
472
+ );
473
+
474
+ expect(() =>
475
+ normalizeDiscoveryUrls(
476
+ doc,
477
+ "https://idp.example.com",
478
+ (url) => !url.endsWith("/userinfo"),
479
+ ),
480
+ ).toThrowError(
481
+ expect.objectContaining({
482
+ code: "discovery_untrusted_origin",
483
+ message:
484
+ 'The userinfo_endpoint "https://idp.example.com/userinfo" is not trusted by your trusted origins configuration.',
485
+ details: {
486
+ endpoint: "userinfo_endpoint",
487
+ url: "https://idp.example.com/userinfo",
488
+ },
489
+ }),
490
+ );
491
+
492
+ expect(() =>
493
+ normalizeDiscoveryUrls(
494
+ doc,
495
+ "https://idp.example.com",
496
+ (url) => !url.endsWith("/revoke"),
497
+ ),
498
+ ).toThrowError(
499
+ expect.objectContaining({
500
+ code: "discovery_untrusted_origin",
501
+ message:
502
+ 'The revocation_endpoint "https://idp.example.com/revoke" is not trusted by your trusted origins configuration.',
503
+ details: {
504
+ endpoint: "revocation_endpoint",
505
+ url: "https://idp.example.com/revoke",
506
+ },
507
+ }),
508
+ );
509
+
510
+ expect(() =>
511
+ normalizeDiscoveryUrls(
512
+ doc,
513
+ "https://idp.example.com",
514
+ (url) => !url.endsWith("/endsession"),
515
+ ),
516
+ ).toThrowError(
517
+ expect.objectContaining({
518
+ code: "discovery_untrusted_origin",
519
+ message:
520
+ 'The end_session_endpoint "https://idp.example.com/endsession" is not trusted by your trusted origins configuration.',
521
+ details: {
522
+ endpoint: "end_session_endpoint",
523
+ url: "https://idp.example.com/endsession",
524
+ },
525
+ }),
526
+ );
527
+
528
+ expect(() =>
529
+ normalizeDiscoveryUrls(
530
+ doc,
531
+ "https://idp.example.com",
532
+ (url) => !url.endsWith("/introspection"),
533
+ ),
534
+ ).toThrowError(
535
+ expect.objectContaining({
536
+ code: "discovery_untrusted_origin",
537
+ message:
538
+ 'The introspection_endpoint "https://idp.example.com/introspection" is not trusted by your trusted origins configuration.',
539
+ details: {
540
+ endpoint: "introspection_endpoint",
541
+ url: "https://idp.example.com/introspection",
542
+ },
543
+ }),
544
+ );
545
+ });
546
+ });
547
+
548
+ describe("normalizeUrl", () => {
549
+ it("should return endpoint unchanged if already absolute", () => {
550
+ const endpoint = "https://idp.example.com/oauth2/token";
551
+ expect(normalizeUrl("url", endpoint, "https://idp.example.com")).toBe(
552
+ endpoint,
553
+ );
554
+ });
555
+
556
+ it("should return endpoint as an absolute url", () => {
557
+ const endpoint = "/oauth2/token";
558
+ expect(normalizeUrl("url", endpoint, "https://idp.example.com")).toBe(
559
+ "https://idp.example.com/oauth2/token",
560
+ );
561
+ });
562
+
563
+ it.each([
564
+ [
565
+ "/oauth2/token",
566
+ "https://idp.example.com/base",
567
+ "endpoint with leading slash",
568
+ ],
569
+ [
570
+ "oauth2/token",
571
+ "https://idp.example.com/base",
572
+ "endpoint without leading slash",
573
+ ],
574
+ [
575
+ "/oauth2/token",
576
+ "https://idp.example.com/base/",
577
+ "issuer with trailing slash",
578
+ ],
579
+ ["//oauth2/token", "https://idp.example.com/base//", "multiple slashes"],
580
+ ])("should resolve relative endpoint preserving issuer base path (%s, %s) - %s", (endpoint, issuer) => {
581
+ expect(normalizeUrl("url", endpoint, issuer)).toBe(
582
+ "https://idp.example.com/base/oauth2/token",
583
+ );
584
+ });
585
+
586
+ it("should reject invalid endpoint urls", () => {
587
+ const endpoint = "oauth2/token";
588
+ const issuer = "not-a-url";
589
+ expect(() => normalizeUrl("url", endpoint, issuer)).toThrowError(
590
+ 'The url "url" must be valid',
591
+ );
592
+ });
593
+
594
+ it("should reject urls with unsupported protocols", () => {
595
+ const endpoint = "not-a-url";
596
+ const issuer = "ftp://idp.example.com";
597
+ expect(() => normalizeUrl("url", endpoint, issuer)).toThrowError(
598
+ 'The url "url" must use the http or https supported protocols',
599
+ );
600
+ });
601
+ });
602
+
603
+ describe("needsRuntimeDiscovery", () => {
604
+ it("should return true for undefined config", () => {
605
+ expect(needsRuntimeDiscovery(undefined)).toBe(true);
606
+ });
607
+
608
+ it("should return true for empty config", () => {
609
+ expect(needsRuntimeDiscovery({})).toBe(true);
610
+ });
611
+
612
+ it("should return true if tokenEndpoint is missing", () => {
613
+ expect(
614
+ needsRuntimeDiscovery({
615
+ jwksEndpoint: "https://idp.example.com/.well-known/jwks.json",
616
+ }),
617
+ ).toBe(true);
618
+ });
619
+
620
+ it("should return true if jwksEndpoint is missing", () => {
621
+ expect(
622
+ needsRuntimeDiscovery({
623
+ tokenEndpoint: "https://idp.example.com/oauth2/token",
624
+ }),
625
+ ).toBe(true);
626
+ });
627
+
628
+ it("should return false if both tokenEndpoint and jwksEndpoint are present", () => {
629
+ expect(
630
+ needsRuntimeDiscovery({
631
+ tokenEndpoint: "https://idp.example.com/oauth2/token",
632
+ jwksEndpoint: "https://idp.example.com/.well-known/jwks.json",
633
+ }),
634
+ ).toBe(false);
635
+ });
636
+ });
637
+
638
+ describe("fetchDiscoveryDocument", () => {
639
+ const mockBetterFetch = betterFetch as ReturnType<typeof vi.fn>;
640
+
641
+ beforeEach(() => {
642
+ vi.clearAllMocks();
643
+ });
644
+
645
+ it("should fetch and parse valid discovery document", async () => {
646
+ const expectedDoc = createMockDiscoveryDocument();
647
+ mockBetterFetch.mockResolvedValueOnce({
648
+ data: expectedDoc,
649
+ error: null,
650
+ });
651
+
652
+ const result = await fetchDiscoveryDocument(
653
+ "https://idp.example.com/.well-known/openid-configuration",
654
+ );
655
+
656
+ expect(result.issuer).toBe(expectedDoc.issuer);
657
+ expect(result.authorization_endpoint).toBe(
658
+ expectedDoc.authorization_endpoint,
659
+ );
660
+ expect(result.token_endpoint).toBe(expectedDoc.token_endpoint);
661
+ expect(result.jwks_uri).toBe(expectedDoc.jwks_uri);
662
+ expect(mockBetterFetch).toHaveBeenCalledWith(
663
+ "https://idp.example.com/.well-known/openid-configuration",
664
+ expect.objectContaining({ method: "GET" }),
665
+ );
666
+ });
667
+
668
+ it("should throw discovery_not_found for 404 response", async () => {
669
+ mockBetterFetch.mockResolvedValueOnce({
670
+ data: null,
671
+ error: { status: 404, message: "Not Found" },
672
+ });
673
+
674
+ await expect(
675
+ fetchDiscoveryDocument(
676
+ "https://idp.example.com/.well-known/openid-configuration",
677
+ ),
678
+ ).rejects.toThrow(
679
+ expect.objectContaining({
680
+ code: "discovery_not_found",
681
+ }),
682
+ );
683
+ });
684
+
685
+ it("should throw discovery_timeout on AbortError (betterFetch throws on timeout)", async () => {
686
+ // betterFetch throws AbortError when timeout fires, not response.error
687
+ const abortError = new Error("The operation was aborted");
688
+ abortError.name = "AbortError";
689
+ mockBetterFetch.mockRejectedValueOnce(abortError);
690
+
691
+ await expect(
692
+ fetchDiscoveryDocument(
693
+ "https://idp.example.com/.well-known/openid-configuration",
694
+ 100,
695
+ ),
696
+ ).rejects.toThrow(
697
+ expect.objectContaining({
698
+ code: "discovery_timeout",
699
+ }),
700
+ );
701
+ });
702
+
703
+ it("should throw discovery_timeout on HTTP 408 response", async () => {
704
+ // HTTP 408 comes as response.error (server responded)
705
+ mockBetterFetch.mockResolvedValueOnce({
706
+ data: null,
707
+ error: { status: 408, statusText: "Request Timeout", message: "" },
708
+ });
709
+
710
+ await expect(
711
+ fetchDiscoveryDocument(
712
+ "https://idp.example.com/.well-known/openid-configuration",
713
+ 100,
714
+ ),
715
+ ).rejects.toThrow(
716
+ expect.objectContaining({
717
+ code: "discovery_timeout",
718
+ }),
719
+ );
720
+ });
721
+
722
+ it("should throw discovery_unexpected_error for server errors", async () => {
723
+ mockBetterFetch.mockResolvedValueOnce({
724
+ data: null,
725
+ error: { status: 500, message: "Internal Server Error" },
726
+ });
727
+
728
+ await expect(
729
+ fetchDiscoveryDocument(
730
+ "https://idp.example.com/.well-known/openid-configuration",
731
+ ),
732
+ ).rejects.toThrow(
733
+ expect.objectContaining({
734
+ code: "discovery_unexpected_error",
735
+ }),
736
+ );
737
+ });
738
+
739
+ it("should throw discovery_invalid_json for empty response", async () => {
740
+ mockBetterFetch.mockResolvedValueOnce({
741
+ data: null,
742
+ error: null,
743
+ });
744
+
745
+ await expect(
746
+ fetchDiscoveryDocument(
747
+ "https://idp.example.com/.well-known/openid-configuration",
748
+ ),
749
+ ).rejects.toThrow(
750
+ expect.objectContaining({
751
+ code: "discovery_invalid_json",
752
+ }),
753
+ );
754
+ });
755
+
756
+ it("should throw discovery_invalid_json for JSON parse errors", async () => {
757
+ // betterFetch doesn't throw SyntaxError - it falls back to raw text
758
+ mockBetterFetch.mockResolvedValueOnce({
759
+ data: "<!DOCTYPE html><html>Not JSON</html>",
760
+ error: null,
761
+ });
762
+
763
+ await expect(
764
+ fetchDiscoveryDocument(
765
+ "https://idp.example.com/.well-known/openid-configuration",
766
+ ),
767
+ ).rejects.toThrow(
768
+ expect.objectContaining({
769
+ code: "discovery_invalid_json",
770
+ details: expect.objectContaining({
771
+ bodyPreview: "<!DOCTYPE html><html>Not JSON</html>",
772
+ }),
773
+ }),
774
+ );
775
+ });
776
+
777
+ it("should throw discovery_unexpected_error for unknown errors", async () => {
778
+ mockBetterFetch.mockRejectedValueOnce(new Error("Network failure"));
779
+
780
+ await expect(
781
+ fetchDiscoveryDocument(
782
+ "https://idp.example.com/.well-known/openid-configuration",
783
+ ),
784
+ ).rejects.toThrow(
785
+ expect.objectContaining({
786
+ code: "discovery_unexpected_error",
787
+ }),
788
+ );
789
+ });
790
+ });
791
+
792
+ describe("discoverOIDCConfig (integration)", () => {
793
+ const mockBetterFetch = betterFetch as ReturnType<typeof vi.fn>;
794
+ const issuer = "https://idp.example.com";
795
+ const isTrustedOrigin = vi.fn().mockReturnValue(true);
796
+
797
+ beforeEach(() => {
798
+ vi.clearAllMocks();
799
+ });
800
+
801
+ it("should return hydrated config from valid discovery", async () => {
802
+ const discoveryDoc = createMockDiscoveryDocument({
803
+ issuer,
804
+ authorization_endpoint: `${issuer}/oauth2/authorize`,
805
+ token_endpoint: `${issuer}/oauth2/token`,
806
+ jwks_uri: `${issuer}/.well-known/jwks.json`,
807
+ userinfo_endpoint: `${issuer}/userinfo`,
808
+ });
809
+ mockBetterFetch.mockResolvedValueOnce({
810
+ data: discoveryDoc,
811
+ error: null,
812
+ });
813
+
814
+ const result = await discoverOIDCConfig({ issuer, isTrustedOrigin });
815
+
816
+ expect(result.issuer).toBe(issuer);
817
+ expect(result.authorizationEndpoint).toBe(`${issuer}/oauth2/authorize`);
818
+ expect(result.tokenEndpoint).toBe(`${issuer}/oauth2/token`);
819
+ expect(result.jwksEndpoint).toBe(`${issuer}/.well-known/jwks.json`);
820
+ expect(result.userInfoEndpoint).toBe(`${issuer}/userinfo`);
821
+ expect(result.discoveryEndpoint).toBe(
822
+ `${issuer}/.well-known/openid-configuration`,
823
+ );
824
+ expect(result.tokenEndpointAuthentication).toBe("client_secret_basic");
825
+ });
826
+
827
+ it("should merge existing config with discovered values (existing takes precedence)", async () => {
828
+ const discoveryDoc = createMockDiscoveryDocument({
829
+ issuer,
830
+ authorization_endpoint: `${issuer}/oauth2/authorize`,
831
+ token_endpoint: `${issuer}/oauth2/token`,
832
+ jwks_uri: `${issuer}/.well-known/jwks.json`,
833
+ });
834
+ mockBetterFetch.mockResolvedValueOnce({
835
+ data: discoveryDoc,
836
+ error: null,
837
+ });
838
+
839
+ const result = await discoverOIDCConfig({
840
+ issuer,
841
+ existingConfig: {
842
+ tokenEndpoint: "https://custom.example.com/token",
843
+ tokenEndpointAuthentication: "client_secret_post",
844
+ },
845
+ isTrustedOrigin,
846
+ });
847
+
848
+ expect(result.tokenEndpoint).toBe("https://custom.example.com/token");
849
+ expect(result.tokenEndpointAuthentication).toBe("client_secret_post");
850
+
851
+ expect(result.authorizationEndpoint).toBe(`${issuer}/oauth2/authorize`);
852
+ expect(result.jwksEndpoint).toBe(`${issuer}/.well-known/jwks.json`);
853
+ });
854
+
855
+ it("should use custom discovery endpoint if provided", async () => {
856
+ const customEndpoint = `${issuer}/custom/.well-known/openid-configuration`;
857
+ mockBetterFetch.mockResolvedValueOnce({
858
+ data: createMockDiscoveryDocument({ issuer }),
859
+ error: null,
860
+ });
861
+
862
+ const result = await discoverOIDCConfig({
863
+ issuer,
864
+ discoveryEndpoint: customEndpoint,
865
+ isTrustedOrigin,
866
+ });
867
+
868
+ expect(result.discoveryEndpoint).toBe(customEndpoint);
869
+ expect(mockBetterFetch).toHaveBeenCalledWith(
870
+ customEndpoint,
871
+ expect.any(Object),
872
+ );
873
+ });
874
+
875
+ it("should use discovery endpoint from existing config", async () => {
876
+ const existingEndpoint = `${issuer}/tenant/.well-known/openid-configuration`;
877
+ mockBetterFetch.mockResolvedValueOnce({
878
+ data: createMockDiscoveryDocument({ issuer }),
879
+ error: null,
880
+ });
881
+
882
+ const result = await discoverOIDCConfig({
883
+ issuer,
884
+ existingConfig: {
885
+ discoveryEndpoint: existingEndpoint,
886
+ },
887
+ isTrustedOrigin,
888
+ });
889
+
890
+ expect(result.discoveryEndpoint).toBe(existingEndpoint);
891
+ expect(mockBetterFetch).toHaveBeenCalledWith(
892
+ existingEndpoint,
893
+ expect.any(Object),
894
+ );
895
+ });
896
+
897
+ it("should throw on issuer mismatch", async () => {
898
+ mockBetterFetch.mockResolvedValueOnce({
899
+ data: createMockDiscoveryDocument({
900
+ issuer: "https://evil.example.com",
901
+ }),
902
+ error: null,
903
+ });
904
+
905
+ await expect(
906
+ discoverOIDCConfig({ issuer, isTrustedOrigin }),
907
+ ).rejects.toThrow(
908
+ expect.objectContaining({
909
+ code: "issuer_mismatch",
910
+ }),
911
+ );
912
+ });
913
+
914
+ it("should throw on missing required fields", async () => {
915
+ mockBetterFetch.mockResolvedValueOnce({
916
+ data: {
917
+ issuer,
918
+ authorization_endpoint: `${issuer}/authorize`,
919
+ },
920
+ error: null,
921
+ });
922
+
923
+ await expect(
924
+ discoverOIDCConfig({ issuer, isTrustedOrigin }),
925
+ ).rejects.toThrow(
926
+ expect.objectContaining({
927
+ code: "discovery_incomplete",
928
+ }),
929
+ );
930
+ });
931
+
932
+ it("should throw discovery_not_found when endpoint doesn't exist", async () => {
933
+ mockBetterFetch.mockResolvedValueOnce({
934
+ data: null,
935
+ error: { status: 404, message: "Not Found" },
936
+ });
937
+
938
+ await expect(
939
+ discoverOIDCConfig({ issuer, isTrustedOrigin }),
940
+ ).rejects.toThrow(
941
+ expect.objectContaining({
942
+ code: "discovery_not_found",
943
+ }),
944
+ );
945
+ });
946
+
947
+ it("should include scopes_supported in hydrated config", async () => {
948
+ const scopes = ["openid", "profile", "email", "offline_access", "custom"];
949
+ mockBetterFetch.mockResolvedValueOnce({
950
+ data: createMockDiscoveryDocument({
951
+ issuer,
952
+ scopes_supported: scopes,
953
+ }),
954
+ error: null,
955
+ });
956
+
957
+ const result = await discoverOIDCConfig({ issuer, isTrustedOrigin });
958
+
959
+ expect(result.scopesSupported).toEqual(scopes);
960
+ });
961
+
962
+ it("should handle discovery document without optional fields", async () => {
963
+ mockBetterFetch.mockResolvedValueOnce({
964
+ data: {
965
+ issuer,
966
+ authorization_endpoint: `${issuer}/authorize`,
967
+ token_endpoint: `${issuer}/token`,
968
+ jwks_uri: `${issuer}/jwks`,
969
+ },
970
+ error: null,
971
+ });
972
+
973
+ const result = await discoverOIDCConfig({ issuer, isTrustedOrigin });
974
+
975
+ expect(result.issuer).toBe(issuer);
976
+ expect(result.authorizationEndpoint).toBe(`${issuer}/authorize`);
977
+ expect(result.tokenEndpoint).toBe(`${issuer}/token`);
978
+ expect(result.jwksEndpoint).toBe(`${issuer}/jwks`);
979
+ expect(result.userInfoEndpoint).toBeUndefined();
980
+ expect(result.scopesSupported).toBeUndefined();
981
+ expect(result.tokenEndpointAuthentication).toBe("client_secret_basic");
982
+ });
983
+
984
+ it("should keep all existing config fields and only fill missing ones from discovery", async () => {
985
+ const discoveryDoc = createMockDiscoveryDocument({ issuer });
986
+ mockBetterFetch.mockResolvedValueOnce({
987
+ data: discoveryDoc,
988
+ error: null,
989
+ });
990
+
991
+ const result = await discoverOIDCConfig({
992
+ issuer,
993
+ existingConfig: {
994
+ issuer,
995
+ discoveryEndpoint:
996
+ "https://custom.example.com/.well-known/openid-configuration",
997
+ authorizationEndpoint: "https://custom.example.com/auth",
998
+ tokenEndpoint: "https://custom.example.com/token",
999
+ jwksEndpoint: "https://custom.example.com/jwks",
1000
+ userInfoEndpoint: "https://custom.example.com/userinfo",
1001
+ tokenEndpointAuthentication: "client_secret_post",
1002
+ scopesSupported: ["openid", "profile"],
1003
+ },
1004
+ isTrustedOrigin,
1005
+ });
1006
+
1007
+ expect(result.issuer).toBe(issuer);
1008
+ expect(result.discoveryEndpoint).toBe(
1009
+ "https://custom.example.com/.well-known/openid-configuration",
1010
+ );
1011
+ expect(result.authorizationEndpoint).toBe(
1012
+ "https://custom.example.com/auth",
1013
+ );
1014
+ expect(result.tokenEndpoint).toBe("https://custom.example.com/token");
1015
+ expect(result.jwksEndpoint).toBe("https://custom.example.com/jwks");
1016
+ expect(result.userInfoEndpoint).toBe(
1017
+ "https://custom.example.com/userinfo",
1018
+ );
1019
+ expect(result.tokenEndpointAuthentication).toBe("client_secret_post");
1020
+ expect(result.scopesSupported).toEqual(["openid", "profile"]);
1021
+ });
1022
+
1023
+ it("should default to client_secret_basic when IdP only supports methods we don't support", async () => {
1024
+ mockBetterFetch.mockResolvedValueOnce({
1025
+ data: {
1026
+ issuer,
1027
+ authorization_endpoint: `${issuer}/authorize`,
1028
+ token_endpoint: `${issuer}/token`,
1029
+ jwks_uri: `${issuer}/jwks`,
1030
+ token_endpoint_auth_methods_supported: ["private_key_jwt"],
1031
+ },
1032
+ error: null,
1033
+ });
1034
+
1035
+ const result = await discoverOIDCConfig({ issuer, isTrustedOrigin });
1036
+ expect(result.tokenEndpointAuthentication).toBe("client_secret_basic");
1037
+ });
1038
+
1039
+ it("should fill missing fields from discovery when existing config is partial", async () => {
1040
+ // Scenario: Legacy provider only has jwksEndpoint stored, discovery fills the rest
1041
+ const discoveryDoc = createMockDiscoveryDocument({
1042
+ issuer,
1043
+ authorization_endpoint: `${issuer}/oauth2/authorize`,
1044
+ token_endpoint: `${issuer}/oauth2/token`,
1045
+ jwks_uri: `${issuer}/.well-known/jwks.json`,
1046
+ userinfo_endpoint: `${issuer}/userinfo`,
1047
+ });
1048
+ mockBetterFetch.mockResolvedValueOnce({
1049
+ data: discoveryDoc,
1050
+ error: null,
1051
+ });
1052
+
1053
+ const result = await discoverOIDCConfig({
1054
+ issuer,
1055
+ existingConfig: {
1056
+ // Only jwksEndpoint is set (simulating a legacy/partial config)
1057
+ jwksEndpoint: "https://custom.example.com/jwks",
1058
+ },
1059
+ isTrustedOrigin,
1060
+ });
1061
+
1062
+ // Existing value should be preserved
1063
+ expect(result.jwksEndpoint).toBe("https://custom.example.com/jwks");
1064
+
1065
+ // Discovered values should fill the gaps
1066
+ expect(result.issuer).toBe(issuer);
1067
+ expect(result.authorizationEndpoint).toBe(`${issuer}/oauth2/authorize`);
1068
+ expect(result.tokenEndpoint).toBe(`${issuer}/oauth2/token`);
1069
+ expect(result.userInfoEndpoint).toBe(`${issuer}/userinfo`);
1070
+ expect(result.tokenEndpointAuthentication).toBe("client_secret_basic");
1071
+ });
1072
+
1073
+ it("should handle discovery document with extra unknown fields and missing optional fields", async () => {
1074
+ // Scenario: IdP returns extra vendor-specific fields and omits all optional fields
1075
+ mockBetterFetch.mockResolvedValueOnce({
1076
+ data: {
1077
+ // Only required fields
1078
+ issuer,
1079
+ authorization_endpoint: `${issuer}/authorize`,
1080
+ token_endpoint: `${issuer}/token`,
1081
+ jwks_uri: `${issuer}/jwks`,
1082
+ // Extra vendor-specific fields (should be ignored but not cause errors)
1083
+ "x-vendor-feature": true,
1084
+ custom_logout_endpoint: `${issuer}/logout`,
1085
+ experimental_flags: { feature_a: true, feature_b: false },
1086
+ },
1087
+ error: null,
1088
+ });
1089
+
1090
+ const result = await discoverOIDCConfig({ issuer, isTrustedOrigin });
1091
+
1092
+ // Should successfully extract required fields
1093
+ expect(result.issuer).toBe(issuer);
1094
+ expect(result.authorizationEndpoint).toBe(`${issuer}/authorize`);
1095
+ expect(result.tokenEndpoint).toBe(`${issuer}/token`);
1096
+ expect(result.jwksEndpoint).toBe(`${issuer}/jwks`);
1097
+
1098
+ // Optional fields should be undefined (not error)
1099
+ expect(result.userInfoEndpoint).toBeUndefined();
1100
+ expect(result.scopesSupported).toBeUndefined();
1101
+
1102
+ // Should default auth method when not specified
1103
+ expect(result.tokenEndpointAuthentication).toBe("client_secret_basic");
1104
+ });
1105
+
1106
+ it("should throw an error with discovery_untrusted_origin code when the main discovery url is untrusted", async () => {
1107
+ isTrustedOrigin.mockReturnValue(false);
1108
+
1109
+ await expect(
1110
+ discoverOIDCConfig({ issuer, isTrustedOrigin }),
1111
+ ).rejects.toThrow(
1112
+ expect.objectContaining({
1113
+ name: "DiscoveryError",
1114
+ message:
1115
+ 'The main discovery endpoint "https://idp.example.com/.well-known/openid-configuration" is not trusted by your trusted origins configuration.',
1116
+ code: "discovery_untrusted_origin",
1117
+ details: {
1118
+ url: "https://idp.example.com/.well-known/openid-configuration",
1119
+ },
1120
+ }),
1121
+ );
1122
+ });
1123
+
1124
+ it("should throw an error with discovery_untrusted_origin code when discovered urls are untrusted", async () => {
1125
+ isTrustedOrigin.mockImplementation((url: string) => {
1126
+ return url.endsWith(".well-known/openid-configuration");
1127
+ });
1128
+
1129
+ const discoveryDoc = createMockDiscoveryDocument({
1130
+ issuer,
1131
+ authorization_endpoint: `${issuer}/oauth2/authorize`,
1132
+ token_endpoint: `${issuer}/oauth2/token`,
1133
+ jwks_uri: `${issuer}/.well-known/jwks.json`,
1134
+ userinfo_endpoint: `${issuer}/userinfo`,
1135
+ });
1136
+ mockBetterFetch.mockResolvedValueOnce({
1137
+ data: discoveryDoc,
1138
+ error: null,
1139
+ });
1140
+
1141
+ await expect(
1142
+ discoverOIDCConfig({ issuer, isTrustedOrigin }),
1143
+ ).rejects.toThrow(
1144
+ expect.objectContaining({
1145
+ name: "DiscoveryError",
1146
+ message:
1147
+ 'The token_endpoint "https://idp.example.com/oauth2/token" is not trusted by your trusted origins configuration.',
1148
+ code: "discovery_untrusted_origin",
1149
+ details: {
1150
+ endpoint: "token_endpoint",
1151
+ url: "https://idp.example.com/oauth2/token",
1152
+ },
1153
+ }),
1154
+ );
1155
+ });
1156
+ });
1157
+ });