@better-auth/sso 1.5.0-beta.13 → 1.5.0-beta.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@better-auth/sso",
3
3
  "author": "Bereket Engida",
4
- "version": "1.5.0-beta.13",
4
+ "version": "1.5.0-beta.16",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
7
7
  "types": "dist/index.d.mts",
@@ -62,19 +62,19 @@
62
62
  "devDependencies": {
63
63
  "@types/body-parser": "^1.19.6",
64
64
  "@types/express": "^5.0.6",
65
- "better-call": "1.2.1",
65
+ "better-call": "1.3.2",
66
66
  "body-parser": "^2.2.2",
67
67
  "express": "^5.2.1",
68
68
  "oauth2-mock-server": "^8.2.1",
69
- "tsdown": "^0.20.1",
70
- "@better-auth/core": "1.5.0-beta.13",
71
- "better-auth": "1.5.0-beta.13"
69
+ "tsdown": "^0.20.3",
70
+ "better-auth": "1.5.0-beta.16",
71
+ "@better-auth/core": "1.5.0-beta.16"
72
72
  },
73
73
  "peerDependencies": {
74
74
  "@better-auth/utils": "0.3.1",
75
- "better-call": "1.2.1",
76
- "@better-auth/core": "1.5.0-beta.13",
77
- "better-auth": "1.5.0-beta.13"
75
+ "better-call": "1.3.2",
76
+ "@better-auth/core": "1.5.0-beta.16",
77
+ "better-auth": "1.5.0-beta.16"
78
78
  },
79
79
  "scripts": {
80
80
  "test": "vitest",
package/src/client.ts CHANGED
@@ -23,7 +23,7 @@ export const ssoClient = <CO extends SSOClientOptions>(
23
23
  }>,
24
24
  pathMethods: {
25
25
  "/sso/providers": "GET",
26
- "/sso/providers/:providerId": "GET",
26
+ "/sso/get-provider": "GET",
27
27
  },
28
28
  } satisfies BetterAuthClientPlugin;
29
29
  };
package/src/constants.ts CHANGED
@@ -14,6 +14,15 @@ export const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
14
14
  /** Prefix for used Assertion IDs used in replay protection */
15
15
  export const USED_ASSERTION_KEY_PREFIX = "saml-used-assertion:";
16
16
 
17
+ /** Prefix for SAML session data (NameID + SessionIndex) for SLO */
18
+ export const SAML_SESSION_KEY_PREFIX = "saml-session:";
19
+
20
+ /** Prefix for reverse lookup of SAML session by Better Auth session ID */
21
+ export const SAML_SESSION_BY_ID_PREFIX = "saml-session-by-id:";
22
+
23
+ /** Prefix for LogoutRequest IDs used in SP-initiated SLO validation */
24
+ export const LOGOUT_REQUEST_KEY_PREFIX = "saml-logout-request:";
25
+
17
26
  // ============================================================================
18
27
  // Time-To-Live (TTL) Defaults
19
28
  // ============================================================================
@@ -30,6 +39,12 @@ export const DEFAULT_AUTHN_REQUEST_TTL_MS = 5 * 60 * 1000;
30
39
  */
31
40
  export const DEFAULT_ASSERTION_TTL_MS = 15 * 60 * 1000;
32
41
 
42
+ /**
43
+ * Default TTL for LogoutRequest records (5 minutes).
44
+ * Should be sufficient for IdP to process and respond.
45
+ */
46
+ export const DEFAULT_LOGOUT_REQUEST_TTL_MS = 5 * 60 * 1000;
47
+
33
48
  /**
34
49
  * Default clock skew tolerance (5 minutes).
35
50
  * Allows for minor time differences between IdP and SP servers.
@@ -56,3 +71,9 @@ export const DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024;
56
71
  * Protects against oversized metadata documents.
57
72
  */
58
73
  export const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
74
+
75
+ // ============================================================================
76
+ // SAML Status Codes
77
+ // ============================================================================
78
+
79
+ export const SAML_STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success";
@@ -137,7 +137,6 @@ describe("Domain verification", async () => {
137
137
  };
138
138
 
139
139
  afterEach(() => {
140
- vi.clearAllMocks();
141
140
  vi.useRealTimers();
142
141
  });
143
142
 
@@ -286,7 +285,7 @@ describe("Domain verification", async () => {
286
285
 
287
286
  dnsMock.resolveTxt.mockResolvedValue([
288
287
  [
289
- `better-auth-token-saml-provider-1=${provider.domainVerificationToken}`,
288
+ `_better-auth-token-saml-provider-1=${provider.domainVerificationToken}`,
290
289
  ],
291
290
  ]);
292
291
 
@@ -471,7 +470,7 @@ describe("Domain verification", async () => {
471
470
  "v=spf1 ip4:50.242.118.232/29 include:_spf.google.com include:mail.zendesk.com ~all",
472
471
  ],
473
472
  [
474
- `better-auth-token-saml-provider-1=${provider.domainVerificationToken}`,
473
+ `_better-auth-token-saml-provider-1=${provider.domainVerificationToken}`,
475
474
  ],
476
475
  ]);
477
476
 
@@ -484,6 +483,9 @@ describe("Domain verification", async () => {
484
483
  });
485
484
 
486
485
  expect(response.status).toBe(204);
486
+ expect(dnsMock.resolveTxt).toHaveBeenCalledWith(
487
+ "_better-auth-token-saml-provider-1.hello.com",
488
+ );
487
489
  });
488
490
 
489
491
  it("should verify a provider domain ownership (custom token verification prefix)", async () => {
@@ -498,7 +500,7 @@ describe("Domain verification", async () => {
498
500
  [
499
501
  "v=spf1 ip4:50.242.118.232/29 include:_spf.google.com include:mail.zendesk.com ~all",
500
502
  ],
501
- [`auth-prefix-saml-provider-1=${provider.domainVerificationToken}`],
503
+ [`_auth-prefix-saml-provider-1=${provider.domainVerificationToken}`],
502
504
  ]);
503
505
 
504
506
  const response = await auth.api.verifyDomain({
@@ -510,6 +512,45 @@ describe("Domain verification", async () => {
510
512
  });
511
513
 
512
514
  expect(response.status).toBe(204);
515
+ expect(dnsMock.resolveTxt).toHaveBeenCalledWith(
516
+ "_auth-prefix-saml-provider-1.hello.com",
517
+ );
518
+ });
519
+
520
+ it("should return bad request when provider ID exceeds DNS label limit", async () => {
521
+ const longProviderId = "a".repeat(50);
522
+ const { auth, getAuthHeaders } = createTestAuth();
523
+ const headers = await getAuthHeaders(testUser);
524
+
525
+ await auth.api.registerSSOProvider({
526
+ body: {
527
+ providerId: longProviderId,
528
+ issuer: "http://hello.com:8081",
529
+ domain: "http://hello.com:8081",
530
+ samlConfig: {
531
+ entryPoint: "http://idp.com:",
532
+ cert: "the-cert",
533
+ callbackUrl: "http://hello.com:8081/api/sso/saml2/callback",
534
+ spMetadata: {},
535
+ },
536
+ },
537
+ headers,
538
+ });
539
+
540
+ const response = await auth.api.verifyDomain({
541
+ body: {
542
+ providerId: longProviderId,
543
+ },
544
+ headers,
545
+ asResponse: true,
546
+ });
547
+
548
+ expect(response.status).toBe(400);
549
+ expect(await response.json()).toEqual({
550
+ message:
551
+ "Verification identifier exceeds the DNS label limit of 63 characters",
552
+ code: "IDENTIFIER_TOO_LONG",
553
+ });
513
554
  });
514
555
 
515
556
  it("should fail to verify an already verified domain", async () => {
@@ -519,7 +560,7 @@ describe("Domain verification", async () => {
519
560
 
520
561
  dnsMock.resolveTxt.mockResolvedValue([
521
562
  [
522
- `better-auth-token-saml-provider-1=${provider.domainVerificationToken}`,
563
+ `_better-auth-token-saml-provider-1=${provider.domainVerificationToken}`,
523
564
  ],
524
565
  ]);
525
566
 
package/src/index.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { BetterAuthPlugin } from "better-auth";
2
- import { createAuthMiddleware } from "better-auth/api";
2
+ import { createAuthMiddleware, getSessionFromCtx } from "better-auth/api";
3
3
  import { XMLValidator } from "fast-xml-parser";
4
4
  import * as saml from "samlify";
5
+ import { SAML_SESSION_BY_ID_PREFIX } from "./constants";
5
6
  import { assignOrganizationByDomain } from "./linking";
6
7
  import {
7
8
  requestDomainVerification,
@@ -17,8 +18,11 @@ import {
17
18
  acsEndpoint,
18
19
  callbackSSO,
19
20
  callbackSSOSAML,
21
+ callbackSSOShared,
22
+ initiateSLO,
20
23
  registerSSOProvider,
21
24
  signInSSO,
25
+ sloEndpoint,
22
26
  spMetadata,
23
27
  } from "./routes/sso";
24
28
 
@@ -96,8 +100,11 @@ type SSOEndpoints<O extends SSOOptions> = {
96
100
  registerSSOProvider: ReturnType<typeof registerSSOProvider<O>>;
97
101
  signInSSO: ReturnType<typeof signInSSO>;
98
102
  callbackSSO: ReturnType<typeof callbackSSO>;
103
+ callbackSSOShared: ReturnType<typeof callbackSSOShared>;
99
104
  callbackSSOSAML: ReturnType<typeof callbackSSOSAML>;
100
105
  acsEndpoint: ReturnType<typeof acsEndpoint>;
106
+ sloEndpoint: ReturnType<typeof sloEndpoint>;
107
+ initiateSLO: ReturnType<typeof initiateSLO>;
101
108
  listSSOProviders: ReturnType<typeof listSSOProviders>;
102
109
  getSSOProvider: ReturnType<typeof getSSOProvider>;
103
110
  updateSSOProvider: ReturnType<typeof updateSSOProvider>;
@@ -120,6 +127,7 @@ export type SSOPlugin<O extends SSOOptions> = {
120
127
  const SAML_SKIP_ORIGIN_CHECK_PATHS = [
121
128
  "/sso/saml2/callback", // SP-initiated SSO callback (prefix matches /callback/:providerId)
122
129
  "/sso/saml2/sp/acs", // IdP-initiated SSO ACS (prefix matches /sp/acs/:providerId)
130
+ "/sso/saml2/sp/slo", // IdP-initiated SLO (prefix matches /sp/slo/:providerId)
123
131
  ];
124
132
 
125
133
  export function sso<
@@ -139,6 +147,7 @@ export function sso<O extends SSOOptions>(
139
147
  ): {
140
148
  id: "sso";
141
149
  endpoints: SSOEndpoints<O>;
150
+ options: O;
142
151
  };
143
152
 
144
153
  export function sso<O extends SSOOptions>(
@@ -147,12 +156,15 @@ export function sso<O extends SSOOptions>(
147
156
  const optionsWithStore = options as O;
148
157
 
149
158
  let endpoints = {
150
- spMetadata: spMetadata(),
159
+ spMetadata: spMetadata(optionsWithStore),
151
160
  registerSSOProvider: registerSSOProvider(optionsWithStore),
152
161
  signInSSO: signInSSO(optionsWithStore),
153
162
  callbackSSO: callbackSSO(optionsWithStore),
163
+ callbackSSOShared: callbackSSOShared(optionsWithStore),
154
164
  callbackSSOSAML: callbackSSOSAML(optionsWithStore),
155
165
  acsEndpoint: acsEndpoint(optionsWithStore),
166
+ sloEndpoint: sloEndpoint(optionsWithStore),
167
+ initiateSLO: initiateSLO(optionsWithStore),
156
168
  listSSOProviders: listSSOProviders(),
157
169
  getSSOProvider: getSSOProvider(),
158
170
  updateSSOProvider: updateSSOProvider(optionsWithStore),
@@ -187,6 +199,35 @@ export function sso<O extends SSOOptions>(
187
199
  },
188
200
  endpoints,
189
201
  hooks: {
202
+ before: [
203
+ {
204
+ matcher(context) {
205
+ return context.path === "/sign-out";
206
+ },
207
+ handler: createAuthMiddleware(async (ctx) => {
208
+ if (!options?.saml?.enableSingleLogout) {
209
+ return;
210
+ }
211
+ const session = await getSessionFromCtx(ctx);
212
+ if (!session?.session?.id) {
213
+ return;
214
+ }
215
+ const sessionLookupKey = `${SAML_SESSION_BY_ID_PREFIX}${session.session.id}`;
216
+ const sessionLookup =
217
+ await ctx.context.internalAdapter.findVerificationValue(
218
+ sessionLookupKey,
219
+ );
220
+ if (sessionLookup?.value) {
221
+ await ctx.context.internalAdapter
222
+ .deleteVerificationValue(sessionLookup.value)
223
+ .catch(() => {});
224
+ await ctx.context.internalAdapter
225
+ .deleteVerificationValue(sessionLookupKey)
226
+ .catch(() => {});
227
+ }
228
+ }),
229
+ },
230
+ ],
190
231
  after: [
191
232
  {
192
233
  matcher(context) {
@@ -1,4 +1,4 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
1
+ import { describe, expect, it, vi } from "vitest";
2
2
  import {
3
3
  computeDiscoveryUrl,
4
4
  discoverOIDCConfig,
@@ -560,7 +560,7 @@ describe("OIDC Discovery", () => {
560
560
  );
561
561
  });
562
562
 
563
- it.each([
563
+ it.for([
564
564
  [
565
565
  "/oauth2/token",
566
566
  "https://idp.example.com/base",
@@ -577,8 +577,11 @@ describe("OIDC Discovery", () => {
577
577
  "issuer with trailing slash",
578
578
  ],
579
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(
580
+ ])("should resolve relative endpoint preserving issuer base path (%s, %s) - %s", ([
581
+ endpoint,
582
+ issuer,
583
+ ]) => {
584
+ expect(normalizeUrl("url", endpoint!, issuer!)).toBe(
582
585
  "https://idp.example.com/base/oauth2/token",
583
586
  );
584
587
  });
@@ -638,10 +641,6 @@ describe("OIDC Discovery", () => {
638
641
  describe("fetchDiscoveryDocument", () => {
639
642
  const mockBetterFetch = betterFetch as ReturnType<typeof vi.fn>;
640
643
 
641
- beforeEach(() => {
642
- vi.clearAllMocks();
643
- });
644
-
645
644
  it("should fetch and parse valid discovery document", async () => {
646
645
  const expectedDoc = createMockDiscoveryDocument();
647
646
  mockBetterFetch.mockResolvedValueOnce({
@@ -794,10 +793,6 @@ describe("OIDC Discovery", () => {
794
793
  const issuer = "https://idp.example.com";
795
794
  const isTrustedOrigin = vi.fn().mockReturnValue(true);
796
795
 
797
- beforeEach(() => {
798
- vi.clearAllMocks();
799
- });
800
-
801
796
  it("should return hydrated config from valid discovery", async () => {
802
797
  const discoveryDoc = createMockDiscoveryDocument({
803
798
  issuer,
package/src/oidc.test.ts CHANGED
@@ -3,7 +3,7 @@ import { createAuthClient } from "better-auth/client";
3
3
  import { organization } from "better-auth/plugins";
4
4
  import { getTestInstance } from "better-auth/test";
5
5
  import { OAuth2Server } from "oauth2-mock-server";
6
- import { afterAll, beforeAll, describe, expect, it } from "vitest";
6
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
7
7
  import { sso } from ".";
8
8
  import { ssoClient } from "./client";
9
9
 
@@ -684,3 +684,304 @@ describe("provisioning", async (ctx) => {
684
684
  expect(res.url).toContain("http://localhost:8080/authorize");
685
685
  });
686
686
  });
687
+
688
+ /**
689
+ * @see https://github.com/better-auth/better-auth/issues/7857
690
+ */
691
+ describe("provisionUser should only be called for new users", async () => {
692
+ const provisionUserFn = vi.fn();
693
+ const { auth, signInWithTestUser, customFetchImpl, cookieSetter } =
694
+ await getTestInstance({
695
+ trustedOrigins: ["http://localhost:8080"],
696
+ plugins: [
697
+ sso({
698
+ provisionUser: provisionUserFn,
699
+ }),
700
+ organization(),
701
+ ],
702
+ });
703
+
704
+ const authClient = createAuthClient({
705
+ plugins: [ssoClient()],
706
+ baseURL: "http://localhost:3000",
707
+ fetchOptions: {
708
+ customFetchImpl,
709
+ },
710
+ });
711
+
712
+ beforeAll(async () => {
713
+ await server.issuer.keys.generate("RS256");
714
+ server.service.removeAllListeners("beforeUserinfo");
715
+ server.service.removeAllListeners("beforeTokenSigning");
716
+ server.service.on("beforeUserinfo", (userInfoResponse) => {
717
+ userInfoResponse.body = {
718
+ email: "provision-test@localhost.com",
719
+ name: "Provision Test",
720
+ sub: "provision-test-sub",
721
+ picture: "https://test.com/picture.png",
722
+ email_verified: true,
723
+ };
724
+ userInfoResponse.statusCode = 200;
725
+ });
726
+ server.service.on("beforeTokenSigning", (token) => {
727
+ token.payload.email = "provision-test@localhost.com";
728
+ token.payload.email_verified = true;
729
+ token.payload.name = "Provision Test";
730
+ token.payload.picture = "https://test.com/picture.png";
731
+ });
732
+ await server.start(8080, "localhost");
733
+ });
734
+
735
+ afterAll(async () => {
736
+ await server.stop();
737
+ });
738
+
739
+ async function simulateOAuthFlow(authUrl: string, headers: Headers) {
740
+ let location: string | null = null;
741
+ await betterFetch(authUrl, {
742
+ method: "GET",
743
+ redirect: "manual",
744
+ onError(context) {
745
+ location = context.response.headers.get("location");
746
+ },
747
+ });
748
+
749
+ if (!location) throw new Error("No redirect location found");
750
+
751
+ let callbackURL = "";
752
+ const newHeaders = new Headers();
753
+ await betterFetch(location, {
754
+ method: "GET",
755
+ customFetchImpl,
756
+ headers,
757
+ onError(context) {
758
+ callbackURL = context.response.headers.get("location") || "";
759
+ cookieSetter(newHeaders)(context);
760
+ },
761
+ });
762
+
763
+ return { callbackURL, headers: newHeaders };
764
+ }
765
+
766
+ it("should call provisionUser only on first sign-in (new user), not on subsequent sign-ins", async () => {
767
+ const { headers } = await signInWithTestUser();
768
+ await auth.api.registerSSOProvider({
769
+ body: {
770
+ issuer: server.issuer.url!,
771
+ domain: "localhost.com",
772
+ oidcConfig: {
773
+ clientId: "test",
774
+ clientSecret: "test",
775
+ authorizationEndpoint: `${server.issuer.url}/authorize`,
776
+ tokenEndpoint: `${server.issuer.url}/token`,
777
+ jwksEndpoint: `${server.issuer.url}/jwks`,
778
+ discoveryEndpoint: `${server.issuer.url}/.well-known/openid-configuration`,
779
+ mapping: {
780
+ id: "sub",
781
+ email: "email",
782
+ emailVerified: "email_verified",
783
+ name: "name",
784
+ image: "picture",
785
+ },
786
+ },
787
+ providerId: "provision-test",
788
+ },
789
+ headers,
790
+ });
791
+
792
+ provisionUserFn.mockClear();
793
+
794
+ // First sign-in: new user -> provisionUser should be called
795
+ const signInHeaders1 = new Headers();
796
+ const res1 = await authClient.signIn.sso({
797
+ email: "user@localhost.com",
798
+ callbackURL: "/dashboard",
799
+ fetchOptions: {
800
+ throw: true,
801
+ onSuccess: cookieSetter(signInHeaders1),
802
+ },
803
+ });
804
+ await simulateOAuthFlow(res1.url, signInHeaders1);
805
+ expect(provisionUserFn).toHaveBeenCalledTimes(1);
806
+
807
+ provisionUserFn.mockClear();
808
+
809
+ // Second sign-in: existing user -> provisionUser should NOT be called
810
+ const signInHeaders2 = new Headers();
811
+ const res2 = await authClient.signIn.sso({
812
+ email: "user@localhost.com",
813
+ callbackURL: "/dashboard",
814
+ fetchOptions: {
815
+ throw: true,
816
+ onSuccess: cookieSetter(signInHeaders2),
817
+ },
818
+ });
819
+ await simulateOAuthFlow(res2.url, signInHeaders2);
820
+ expect(provisionUserFn).toHaveBeenCalledTimes(0);
821
+ });
822
+ });
823
+
824
+ /**
825
+ * @see https://github.com/better-auth/better-auth/issues/7693
826
+ */
827
+ describe("SSO shared redirectURI", async () => {
828
+ const { auth, signInWithTestUser, customFetchImpl, cookieSetter } =
829
+ await getTestInstance({
830
+ trustedOrigins: ["http://localhost:8080"],
831
+ plugins: [
832
+ sso({
833
+ redirectURI: "/sso/callback",
834
+ }),
835
+ organization(),
836
+ ],
837
+ });
838
+
839
+ const authClient = createAuthClient({
840
+ plugins: [ssoClient()],
841
+ baseURL: "http://localhost:3000",
842
+ fetchOptions: {
843
+ customFetchImpl,
844
+ },
845
+ });
846
+
847
+ const userinfoHandler = (userInfoResponse: any) => {
848
+ userInfoResponse.body = {
849
+ email: "shared-redirect@test.com",
850
+ name: "Shared Redirect User",
851
+ sub: "shared-redirect-user",
852
+ picture: "https://test.com/shared.png",
853
+ email_verified: true,
854
+ };
855
+ userInfoResponse.statusCode = 200;
856
+ };
857
+
858
+ const tokenHandler = (token: any) => {
859
+ token.payload.email = "shared-redirect@test.com";
860
+ token.payload.email_verified = true;
861
+ token.payload.name = "Shared Redirect User";
862
+ token.payload.picture = "https://test.com/shared.png";
863
+ };
864
+
865
+ beforeAll(async () => {
866
+ await server.issuer.keys.generate("RS256");
867
+ server.service.removeAllListeners("beforeUserinfo");
868
+ server.service.removeAllListeners("beforeTokenSigning");
869
+ server.service.on("beforeUserinfo", userinfoHandler);
870
+ server.service.on("beforeTokenSigning", tokenHandler);
871
+ await server.start(8080, "localhost");
872
+ });
873
+
874
+ afterAll(async () => {
875
+ server.service.removeListener("beforeUserinfo", userinfoHandler);
876
+ server.service.removeListener("beforeTokenSigning", tokenHandler);
877
+ await server.stop().catch(() => {});
878
+ });
879
+
880
+ async function simulateOAuthFlow(
881
+ authUrl: string,
882
+ headers: Headers,
883
+ fetchImpl?: (...args: any) => any,
884
+ ) {
885
+ let location: string | null = null;
886
+ await betterFetch(authUrl, {
887
+ method: "GET",
888
+ redirect: "manual",
889
+ onError(context) {
890
+ location = context.response.headers.get("location");
891
+ },
892
+ });
893
+
894
+ if (!location) throw new Error("No redirect location found");
895
+ const newHeaders = new Headers();
896
+ let callbackURL = "";
897
+ await betterFetch(location, {
898
+ method: "GET",
899
+ customFetchImpl: fetchImpl || customFetchImpl,
900
+ headers,
901
+ onError(context) {
902
+ callbackURL = context.response.headers.get("location") || "";
903
+ cookieSetter(newHeaders)(context);
904
+ },
905
+ });
906
+
907
+ return { callbackURL, headers: newHeaders };
908
+ }
909
+
910
+ it("should return shared redirectURI when registering provider", async () => {
911
+ const { headers } = await signInWithTestUser();
912
+ const provider = await auth.api.registerSSOProvider({
913
+ body: {
914
+ issuer: server.issuer.url!,
915
+ domain: "shared-redirect.com",
916
+ oidcConfig: {
917
+ clientId: "shared-test",
918
+ clientSecret: "shared-test-secret",
919
+ authorizationEndpoint: `${server.issuer.url}/authorize`,
920
+ tokenEndpoint: `${server.issuer.url}/token`,
921
+ jwksEndpoint: `${server.issuer.url}/jwks`,
922
+ discoveryEndpoint: `${server.issuer.url}/.well-known/openid-configuration`,
923
+ mapping: {
924
+ id: "sub",
925
+ email: "email",
926
+ emailVerified: "email_verified",
927
+ name: "name",
928
+ image: "picture",
929
+ },
930
+ },
931
+ providerId: "shared-test",
932
+ },
933
+ headers,
934
+ });
935
+ // Should use the shared redirect URI, not per-provider
936
+ expect(provider.redirectURI).toBe(
937
+ "http://localhost:3000/api/auth/sso/callback",
938
+ );
939
+ expect(provider.redirectURI).not.toContain("shared-test");
940
+ });
941
+
942
+ it("should use shared redirect URI in authorization URL", async () => {
943
+ const headers = new Headers();
944
+ const res = await authClient.signIn.sso({
945
+ email: "user@shared-redirect.com",
946
+ callbackURL: "/dashboard",
947
+ fetchOptions: {
948
+ throw: true,
949
+ onSuccess: cookieSetter(headers),
950
+ },
951
+ });
952
+ expect(res.url).toContain("http://localhost:8080/authorize");
953
+ // Should use shared redirect URI without providerId in path
954
+ expect(res.url).toContain(
955
+ "redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fsso%2Fcallback",
956
+ );
957
+ // Should NOT contain the per-provider path
958
+ expect(res.url).not.toContain(
959
+ "redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fsso%2Fcallback%2Fshared-test",
960
+ );
961
+ });
962
+
963
+ it("should complete OIDC flow using shared callback endpoint", async () => {
964
+ const headers = new Headers();
965
+ const res = await authClient.signIn.sso({
966
+ email: "user@shared-redirect.com",
967
+ callbackURL: "/dashboard",
968
+ fetchOptions: {
969
+ throw: true,
970
+ onSuccess: cookieSetter(headers),
971
+ },
972
+ });
973
+ const { callbackURL, headers: sessionHeaders } = await simulateOAuthFlow(
974
+ res.url,
975
+ headers,
976
+ );
977
+ expect(callbackURL).toContain("/dashboard");
978
+
979
+ // Verify session was created
980
+ const session = await authClient.getSession({
981
+ fetchOptions: {
982
+ headers: sessionHeaders,
983
+ },
984
+ });
985
+ expect(session.data?.user.email).toBe("shared-redirect@test.com");
986
+ });
987
+ });