@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/.turbo/turbo-build.log +11 -11
- package/dist/client.d.mts +3 -2
- package/dist/client.mjs +1 -1
- package/dist/client.mjs.map +1 -1
- package/dist/{index-DCUy0gtM.d.mts → index-CbKvQr9M.d.mts} +129 -65
- package/dist/index.d.mts +56 -2
- package/dist/index.mjs +637 -238
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -8
- package/src/client.ts +1 -1
- package/src/constants.ts +21 -0
- package/src/domain-verification.test.ts +46 -5
- package/src/index.ts +43 -2
- package/src/oidc/discovery.test.ts +7 -12
- package/src/oidc.test.ts +302 -1
- package/src/providers.test.ts +39 -45
- package/src/routes/domain-verification.ts +34 -12
- package/src/routes/helpers.ts +126 -0
- package/src/routes/providers.ts +16 -14
- package/src/routes/sso.ts +932 -365
- package/src/saml/algorithms.test.ts +1 -9
- package/src/saml/error-codes.ts +11 -0
- package/src/saml.test.ts +736 -4
- package/src/types.ts +53 -2
- package/src/utils.test.ts +3 -0
- package/vitest.config.ts +6 -1
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.
|
|
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
|
|
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.
|
|
70
|
-
"
|
|
71
|
-
"better-auth": "1.5.0-beta.
|
|
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
|
|
76
|
-
"@better-auth/core": "1.5.0-beta.
|
|
77
|
-
"better-auth": "1.5.0-beta.
|
|
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
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
|
-
`
|
|
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
|
-
`
|
|
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
|
-
[`
|
|
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
|
-
`
|
|
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 {
|
|
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.
|
|
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", (
|
|
581
|
-
|
|
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
|
+
});
|