@better-auth/sso 1.4.7-beta.4 → 1.4.8-beta.1

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.
@@ -75,10 +75,13 @@ describe("OIDC Discovery", () => {
75
75
  });
76
76
 
77
77
  describe("validateDiscoveryUrl", () => {
78
+ const isTrustedOrigin = vi.fn().mockReturnValue(true);
79
+
78
80
  it("should accept valid HTTPS URL", () => {
79
81
  expect(() =>
80
82
  validateDiscoveryUrl(
81
83
  "https://idp.example.com/.well-known/openid-configuration",
84
+ isTrustedOrigin,
82
85
  ),
83
86
  ).not.toThrow();
84
87
  });
@@ -87,28 +90,31 @@ describe("OIDC Discovery", () => {
87
90
  expect(() =>
88
91
  validateDiscoveryUrl(
89
92
  "http://localhost:8080/.well-known/openid-configuration",
93
+ isTrustedOrigin,
90
94
  ),
91
95
  ).not.toThrow();
92
96
  });
93
97
 
94
98
  it("should reject invalid URL", () => {
95
- expect(() => validateDiscoveryUrl("not-a-url")).toThrow(DiscoveryError);
96
- expect(() => validateDiscoveryUrl("not-a-url")).toThrow(
97
- "Invalid discovery 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',
98
104
  );
99
105
  });
100
106
 
101
107
  it("should reject non-HTTP protocols", () => {
102
- expect(() => validateDiscoveryUrl("ftp://example.com/config")).toThrow(
103
- DiscoveryError,
104
- );
105
- expect(() => validateDiscoveryUrl("ftp://example.com/config")).toThrow(
106
- "must use HTTP or HTTPS",
107
- );
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");
108
114
  });
109
115
 
110
116
  it("should throw DiscoveryError with discovery_invalid_url code for invalid URL", () => {
111
- expect(() => validateDiscoveryUrl("not-a-url")).toThrow(
117
+ expect(() => validateDiscoveryUrl("not-a-url", isTrustedOrigin)).toThrow(
112
118
  expect.objectContaining({
113
119
  code: "discovery_invalid_url",
114
120
  details: expect.objectContaining({
@@ -119,7 +125,9 @@ describe("OIDC Discovery", () => {
119
125
  });
120
126
 
121
127
  it("should throw DiscoveryError with discovery_invalid_url code for non-HTTP protocol", () => {
122
- expect(() => validateDiscoveryUrl("ftp://example.com/config")).toThrow(
128
+ expect(() =>
129
+ validateDiscoveryUrl("ftp://example.com/config", isTrustedOrigin),
130
+ ).toThrow(
123
131
  expect.objectContaining({
124
132
  code: "discovery_invalid_url",
125
133
  details: expect.objectContaining({
@@ -128,6 +136,22 @@ describe("OIDC Discovery", () => {
128
136
  }),
129
137
  );
130
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
+ });
131
155
  });
132
156
 
133
157
  describe("validateDiscoveryDocument", () => {
@@ -314,18 +338,265 @@ describe("OIDC Discovery", () => {
314
338
  });
315
339
  });
316
340
 
317
- describe("normalizeDiscoveryUrls (stub)", () => {
318
- it("should return document unchanged in Phase 1", () => {
341
+ describe("normalizeDiscoveryUrls", () => {
342
+ const isTrustedOrigin = vi.fn().mockReturnValue(true);
343
+
344
+ it("should return the document unchanged if all urls are already absolute", () => {
319
345
  const doc = createMockDiscoveryDocument();
320
- const result = normalizeDiscoveryUrls(doc, "https://idp.example.com");
346
+ const result = normalizeDiscoveryUrls(
347
+ doc,
348
+ "https://idp.example.com",
349
+ isTrustedOrigin,
350
+ );
321
351
  expect(result).toEqual(doc);
322
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
+ });
323
546
  });
324
547
 
325
- describe("normalizeUrl (stub)", () => {
326
- it("should return endpoint unchanged in Phase 1", () => {
548
+ describe("normalizeUrl", () => {
549
+ it("should return endpoint unchanged if already absolute", () => {
327
550
  const endpoint = "https://idp.example.com/oauth2/token";
328
- expect(normalizeUrl(endpoint, "https://idp.example.com")).toBe(endpoint);
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
+ );
329
600
  });
330
601
  });
331
602
 
@@ -521,6 +792,7 @@ describe("OIDC Discovery", () => {
521
792
  describe("discoverOIDCConfig (integration)", () => {
522
793
  const mockBetterFetch = betterFetch as ReturnType<typeof vi.fn>;
523
794
  const issuer = "https://idp.example.com";
795
+ const isTrustedOrigin = vi.fn().mockReturnValue(true);
524
796
 
525
797
  beforeEach(() => {
526
798
  vi.clearAllMocks();
@@ -539,7 +811,7 @@ describe("OIDC Discovery", () => {
539
811
  error: null,
540
812
  });
541
813
 
542
- const result = await discoverOIDCConfig({ issuer });
814
+ const result = await discoverOIDCConfig({ issuer, isTrustedOrigin });
543
815
 
544
816
  expect(result.issuer).toBe(issuer);
545
817
  expect(result.authorizationEndpoint).toBe(`${issuer}/oauth2/authorize`);
@@ -570,6 +842,7 @@ describe("OIDC Discovery", () => {
570
842
  tokenEndpoint: "https://custom.example.com/token",
571
843
  tokenEndpointAuthentication: "client_secret_post",
572
844
  },
845
+ isTrustedOrigin,
573
846
  });
574
847
 
575
848
  expect(result.tokenEndpoint).toBe("https://custom.example.com/token");
@@ -589,6 +862,7 @@ describe("OIDC Discovery", () => {
589
862
  const result = await discoverOIDCConfig({
590
863
  issuer,
591
864
  discoveryEndpoint: customEndpoint,
865
+ isTrustedOrigin,
592
866
  });
593
867
 
594
868
  expect(result.discoveryEndpoint).toBe(customEndpoint);
@@ -610,6 +884,7 @@ describe("OIDC Discovery", () => {
610
884
  existingConfig: {
611
885
  discoveryEndpoint: existingEndpoint,
612
886
  },
887
+ isTrustedOrigin,
613
888
  });
614
889
 
615
890
  expect(result.discoveryEndpoint).toBe(existingEndpoint);
@@ -627,7 +902,9 @@ describe("OIDC Discovery", () => {
627
902
  error: null,
628
903
  });
629
904
 
630
- await expect(discoverOIDCConfig({ issuer })).rejects.toThrow(
905
+ await expect(
906
+ discoverOIDCConfig({ issuer, isTrustedOrigin }),
907
+ ).rejects.toThrow(
631
908
  expect.objectContaining({
632
909
  code: "issuer_mismatch",
633
910
  }),
@@ -643,7 +920,9 @@ describe("OIDC Discovery", () => {
643
920
  error: null,
644
921
  });
645
922
 
646
- await expect(discoverOIDCConfig({ issuer })).rejects.toThrow(
923
+ await expect(
924
+ discoverOIDCConfig({ issuer, isTrustedOrigin }),
925
+ ).rejects.toThrow(
647
926
  expect.objectContaining({
648
927
  code: "discovery_incomplete",
649
928
  }),
@@ -656,7 +935,9 @@ describe("OIDC Discovery", () => {
656
935
  error: { status: 404, message: "Not Found" },
657
936
  });
658
937
 
659
- await expect(discoverOIDCConfig({ issuer })).rejects.toThrow(
938
+ await expect(
939
+ discoverOIDCConfig({ issuer, isTrustedOrigin }),
940
+ ).rejects.toThrow(
660
941
  expect.objectContaining({
661
942
  code: "discovery_not_found",
662
943
  }),
@@ -673,7 +954,7 @@ describe("OIDC Discovery", () => {
673
954
  error: null,
674
955
  });
675
956
 
676
- const result = await discoverOIDCConfig({ issuer });
957
+ const result = await discoverOIDCConfig({ issuer, isTrustedOrigin });
677
958
 
678
959
  expect(result.scopesSupported).toEqual(scopes);
679
960
  });
@@ -689,7 +970,7 @@ describe("OIDC Discovery", () => {
689
970
  error: null,
690
971
  });
691
972
 
692
- const result = await discoverOIDCConfig({ issuer });
973
+ const result = await discoverOIDCConfig({ issuer, isTrustedOrigin });
693
974
 
694
975
  expect(result.issuer).toBe(issuer);
695
976
  expect(result.authorizationEndpoint).toBe(`${issuer}/authorize`);
@@ -720,6 +1001,7 @@ describe("OIDC Discovery", () => {
720
1001
  tokenEndpointAuthentication: "client_secret_post",
721
1002
  scopesSupported: ["openid", "profile"],
722
1003
  },
1004
+ isTrustedOrigin,
723
1005
  });
724
1006
 
725
1007
  expect(result.issuer).toBe(issuer);
@@ -750,7 +1032,7 @@ describe("OIDC Discovery", () => {
750
1032
  error: null,
751
1033
  });
752
1034
 
753
- const result = await discoverOIDCConfig({ issuer });
1035
+ const result = await discoverOIDCConfig({ issuer, isTrustedOrigin });
754
1036
  expect(result.tokenEndpointAuthentication).toBe("client_secret_basic");
755
1037
  });
756
1038
 
@@ -774,6 +1056,7 @@ describe("OIDC Discovery", () => {
774
1056
  // Only jwksEndpoint is set (simulating a legacy/partial config)
775
1057
  jwksEndpoint: "https://custom.example.com/jwks",
776
1058
  },
1059
+ isTrustedOrigin,
777
1060
  });
778
1061
 
779
1062
  // Existing value should be preserved
@@ -804,7 +1087,7 @@ describe("OIDC Discovery", () => {
804
1087
  error: null,
805
1088
  });
806
1089
 
807
- const result = await discoverOIDCConfig({ issuer });
1090
+ const result = await discoverOIDCConfig({ issuer, isTrustedOrigin });
808
1091
 
809
1092
  // Should successfully extract required fields
810
1093
  expect(result.issuer).toBe(issuer);
@@ -819,5 +1102,56 @@ describe("OIDC Discovery", () => {
819
1102
  // Should default auth method when not specified
820
1103
  expect(result.tokenEndpointAuthentication).toBe("client_secret_basic");
821
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
+ });
822
1156
  });
823
1157
  });