@better-auth/sso 1.4.17 → 1.4.19
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 +10 -8
- package/dist/client.d.mts +7 -2
- package/dist/client.mjs +7 -2
- package/dist/client.mjs.map +1 -0
- package/dist/{index-XUgmj4eH.d.mts → index-D-VInsst.d.mts} +387 -8
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1214 -657
- package/dist/index.mjs.map +1 -0
- package/package.json +4 -3
- package/src/client.ts +5 -1
- package/src/domain-verification.test.ts +46 -4
- package/src/index.ts +45 -6
- package/src/linking/org-assignment.ts +30 -15
- package/src/oidc.test.ts +1 -3
- package/src/providers.test.ts +1326 -0
- package/src/routes/domain-verification.ts +34 -12
- package/src/routes/providers.ts +567 -0
- package/src/routes/schemas.ts +95 -0
- package/src/routes/sso.ts +302 -118
- package/src/saml-state.ts +78 -0
- package/src/saml.test.ts +1660 -229
- package/src/types.ts +13 -2
- package/src/utils.test.ts +103 -0
- package/src/utils.ts +45 -5
- package/tsdown.config.ts +1 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-auth/sso",
|
|
3
3
|
"author": "Bereket Engida",
|
|
4
|
-
"version": "1.4.
|
|
4
|
+
"version": "1.4.19",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.mjs",
|
|
7
7
|
"types": "dist/index.d.mts",
|
|
@@ -67,11 +67,12 @@
|
|
|
67
67
|
"express": "^5.1.0",
|
|
68
68
|
"oauth2-mock-server": "^8.2.0",
|
|
69
69
|
"tsdown": "^0.17.2",
|
|
70
|
-
"better-auth": "1.4.
|
|
70
|
+
"better-auth": "1.4.19"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
73
|
"@better-auth/utils": "0.3.0",
|
|
74
|
-
"better-
|
|
74
|
+
"better-call": "1.1.8",
|
|
75
|
+
"better-auth": "1.4.19"
|
|
75
76
|
},
|
|
76
77
|
"scripts": {
|
|
77
78
|
"test": "vitest",
|
package/src/client.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BetterAuthClientPlugin } from "better-auth";
|
|
1
|
+
import type { BetterAuthClientPlugin } from "better-auth/client";
|
|
2
2
|
import type { SSOPlugin } from "./index";
|
|
3
3
|
|
|
4
4
|
interface SSOClientOptions {
|
|
@@ -21,5 +21,9 @@ export const ssoClient = <CO extends SSOClientOptions>(
|
|
|
21
21
|
: false;
|
|
22
22
|
};
|
|
23
23
|
}>,
|
|
24
|
+
pathMethods: {
|
|
25
|
+
"/sso/providers": "GET",
|
|
26
|
+
"/sso/providers/:providerId": "GET",
|
|
27
|
+
},
|
|
24
28
|
} satisfies BetterAuthClientPlugin;
|
|
25
29
|
};
|
|
@@ -286,7 +286,7 @@ describe("Domain verification", async () => {
|
|
|
286
286
|
|
|
287
287
|
dnsMock.resolveTxt.mockResolvedValue([
|
|
288
288
|
[
|
|
289
|
-
`
|
|
289
|
+
`_better-auth-token-saml-provider-1=${provider.domainVerificationToken}`,
|
|
290
290
|
],
|
|
291
291
|
]);
|
|
292
292
|
|
|
@@ -471,7 +471,7 @@ describe("Domain verification", async () => {
|
|
|
471
471
|
"v=spf1 ip4:50.242.118.232/29 include:_spf.google.com include:mail.zendesk.com ~all",
|
|
472
472
|
],
|
|
473
473
|
[
|
|
474
|
-
`
|
|
474
|
+
`_better-auth-token-saml-provider-1=${provider.domainVerificationToken}`,
|
|
475
475
|
],
|
|
476
476
|
]);
|
|
477
477
|
|
|
@@ -484,6 +484,9 @@ describe("Domain verification", async () => {
|
|
|
484
484
|
});
|
|
485
485
|
|
|
486
486
|
expect(response.status).toBe(204);
|
|
487
|
+
expect(dnsMock.resolveTxt).toHaveBeenCalledWith(
|
|
488
|
+
"_better-auth-token-saml-provider-1.hello.com",
|
|
489
|
+
);
|
|
487
490
|
});
|
|
488
491
|
|
|
489
492
|
it("should verify a provider domain ownership (custom token verification prefix)", async () => {
|
|
@@ -498,7 +501,7 @@ describe("Domain verification", async () => {
|
|
|
498
501
|
[
|
|
499
502
|
"v=spf1 ip4:50.242.118.232/29 include:_spf.google.com include:mail.zendesk.com ~all",
|
|
500
503
|
],
|
|
501
|
-
[`
|
|
504
|
+
[`_auth-prefix-saml-provider-1=${provider.domainVerificationToken}`],
|
|
502
505
|
]);
|
|
503
506
|
|
|
504
507
|
const response = await auth.api.verifyDomain({
|
|
@@ -510,6 +513,45 @@ describe("Domain verification", async () => {
|
|
|
510
513
|
});
|
|
511
514
|
|
|
512
515
|
expect(response.status).toBe(204);
|
|
516
|
+
expect(dnsMock.resolveTxt).toHaveBeenCalledWith(
|
|
517
|
+
"_auth-prefix-saml-provider-1.hello.com",
|
|
518
|
+
);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it("should return bad request when provider ID exceeds DNS label limit", async () => {
|
|
522
|
+
const longProviderId = "a".repeat(50);
|
|
523
|
+
const { auth, getAuthHeaders } = createTestAuth();
|
|
524
|
+
const headers = await getAuthHeaders(testUser);
|
|
525
|
+
|
|
526
|
+
await auth.api.registerSSOProvider({
|
|
527
|
+
body: {
|
|
528
|
+
providerId: longProviderId,
|
|
529
|
+
issuer: "http://hello.com:8081",
|
|
530
|
+
domain: "http://hello.com:8081",
|
|
531
|
+
samlConfig: {
|
|
532
|
+
entryPoint: "http://idp.com:",
|
|
533
|
+
cert: "the-cert",
|
|
534
|
+
callbackUrl: "http://hello.com:8081/api/sso/saml2/callback",
|
|
535
|
+
spMetadata: {},
|
|
536
|
+
},
|
|
537
|
+
},
|
|
538
|
+
headers,
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
const response = await auth.api.verifyDomain({
|
|
542
|
+
body: {
|
|
543
|
+
providerId: longProviderId,
|
|
544
|
+
},
|
|
545
|
+
headers,
|
|
546
|
+
asResponse: true,
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
expect(response.status).toBe(400);
|
|
550
|
+
expect(await response.json()).toEqual({
|
|
551
|
+
message:
|
|
552
|
+
"Verification identifier exceeds the DNS label limit of 63 characters",
|
|
553
|
+
code: "IDENTIFIER_TOO_LONG",
|
|
554
|
+
});
|
|
513
555
|
});
|
|
514
556
|
|
|
515
557
|
it("should fail to verify an already verified domain", async () => {
|
|
@@ -519,7 +561,7 @@ describe("Domain verification", async () => {
|
|
|
519
561
|
|
|
520
562
|
dnsMock.resolveTxt.mockResolvedValue([
|
|
521
563
|
[
|
|
522
|
-
`
|
|
564
|
+
`_better-auth-token-saml-provider-1=${provider.domainVerificationToken}`,
|
|
523
565
|
],
|
|
524
566
|
]);
|
|
525
567
|
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,12 @@ import {
|
|
|
7
7
|
requestDomainVerification,
|
|
8
8
|
verifyDomain,
|
|
9
9
|
} from "./routes/domain-verification";
|
|
10
|
+
import {
|
|
11
|
+
deleteSSOProvider,
|
|
12
|
+
getSSOProvider,
|
|
13
|
+
listSSOProviders,
|
|
14
|
+
updateSSOProvider,
|
|
15
|
+
} from "./routes/providers";
|
|
10
16
|
import {
|
|
11
17
|
acsEndpoint,
|
|
12
18
|
callbackSSO,
|
|
@@ -84,6 +90,10 @@ type SSOEndpoints<O extends SSOOptions> = {
|
|
|
84
90
|
callbackSSO: ReturnType<typeof callbackSSO>;
|
|
85
91
|
callbackSSOSAML: ReturnType<typeof callbackSSOSAML>;
|
|
86
92
|
acsEndpoint: ReturnType<typeof acsEndpoint>;
|
|
93
|
+
listSSOProviders: ReturnType<typeof listSSOProviders>;
|
|
94
|
+
getSSOProvider: ReturnType<typeof getSSOProvider>;
|
|
95
|
+
updateSSOProvider: ReturnType<typeof updateSSOProvider>;
|
|
96
|
+
deleteSSOProvider: ReturnType<typeof deleteSSOProvider>;
|
|
87
97
|
};
|
|
88
98
|
|
|
89
99
|
export type SSOPlugin<O extends SSOOptions> = {
|
|
@@ -94,6 +104,16 @@ export type SSOPlugin<O extends SSOOptions> = {
|
|
|
94
104
|
: {});
|
|
95
105
|
};
|
|
96
106
|
|
|
107
|
+
/**
|
|
108
|
+
* SAML endpoint paths that should skip origin check validation.
|
|
109
|
+
* These endpoints receive POST requests from external Identity Providers,
|
|
110
|
+
* which won't have a matching Origin header.
|
|
111
|
+
*/
|
|
112
|
+
const SAML_SKIP_ORIGIN_CHECK_PATHS = [
|
|
113
|
+
"/sso/saml2/callback", // SP-initiated SSO callback (prefix matches /callback/:providerId)
|
|
114
|
+
"/sso/saml2/sp/acs", // IdP-initiated SSO ACS (prefix matches /sp/acs/:providerId)
|
|
115
|
+
];
|
|
116
|
+
|
|
97
117
|
export function sso<
|
|
98
118
|
O extends SSOOptions & {
|
|
99
119
|
domainVerification?: { enabled: true };
|
|
@@ -103,7 +123,7 @@ export function sso<
|
|
|
103
123
|
): {
|
|
104
124
|
id: "sso";
|
|
105
125
|
endpoints: SSOEndpoints<O> & DomainVerificationEndpoints;
|
|
106
|
-
schema:
|
|
126
|
+
schema: NonNullable<BetterAuthPlugin["schema"]>;
|
|
107
127
|
options: O;
|
|
108
128
|
};
|
|
109
129
|
export function sso<O extends SSOOptions>(
|
|
@@ -113,7 +133,9 @@ export function sso<O extends SSOOptions>(
|
|
|
113
133
|
endpoints: SSOEndpoints<O>;
|
|
114
134
|
};
|
|
115
135
|
|
|
116
|
-
export function sso<O extends SSOOptions>(
|
|
136
|
+
export function sso<O extends SSOOptions>(
|
|
137
|
+
options?: O | undefined,
|
|
138
|
+
): BetterAuthPlugin {
|
|
117
139
|
const optionsWithStore = options as O;
|
|
118
140
|
|
|
119
141
|
let endpoints = {
|
|
@@ -123,6 +145,10 @@ export function sso<O extends SSOOptions>(options?: O | undefined): any {
|
|
|
123
145
|
callbackSSO: callbackSSO(optionsWithStore),
|
|
124
146
|
callbackSSOSAML: callbackSSOSAML(optionsWithStore),
|
|
125
147
|
acsEndpoint: acsEndpoint(optionsWithStore),
|
|
148
|
+
listSSOProviders: listSSOProviders(),
|
|
149
|
+
getSSOProvider: getSSOProvider(),
|
|
150
|
+
updateSSOProvider: updateSSOProvider(optionsWithStore),
|
|
151
|
+
deleteSSOProvider: deleteSSOProvider(),
|
|
126
152
|
};
|
|
127
153
|
|
|
128
154
|
if (options?.domainVerification?.enabled) {
|
|
@@ -139,6 +165,18 @@ export function sso<O extends SSOOptions>(options?: O | undefined): any {
|
|
|
139
165
|
|
|
140
166
|
return {
|
|
141
167
|
id: "sso",
|
|
168
|
+
init(ctx) {
|
|
169
|
+
const existing = ctx.skipOriginCheck;
|
|
170
|
+
if (existing === true) {
|
|
171
|
+
return {};
|
|
172
|
+
}
|
|
173
|
+
const existingPaths = Array.isArray(existing) ? existing : [];
|
|
174
|
+
return {
|
|
175
|
+
context: {
|
|
176
|
+
skipOriginCheck: [...existingPaths, ...SAML_SKIP_ORIGIN_CHECK_PATHS],
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
},
|
|
142
180
|
endpoints,
|
|
143
181
|
hooks: {
|
|
144
182
|
after: [
|
|
@@ -152,10 +190,11 @@ export function sso<O extends SSOOptions>(options?: O | undefined): any {
|
|
|
152
190
|
return;
|
|
153
191
|
}
|
|
154
192
|
|
|
155
|
-
const
|
|
156
|
-
(
|
|
157
|
-
|
|
158
|
-
|
|
193
|
+
const hasOrganizationPlugin =
|
|
194
|
+
ctx.context.options.plugins?.some(
|
|
195
|
+
(plugin) => plugin.id === "organization",
|
|
196
|
+
) ?? false;
|
|
197
|
+
if (!hasOrganizationPlugin) {
|
|
159
198
|
return;
|
|
160
199
|
}
|
|
161
200
|
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import type { GenericEndpointContext, OAuth2Tokens, User } from "better-auth";
|
|
2
2
|
import type { SSOOptions, SSOProvider } from "../types";
|
|
3
|
+
import { domainMatches } from "../utils";
|
|
3
4
|
import type { NormalizedSSOProfile } from "./types";
|
|
4
5
|
|
|
5
6
|
export interface OrganizationProvisioningOptions {
|
|
6
7
|
disabled?: boolean;
|
|
7
|
-
defaultRole?:
|
|
8
|
+
defaultRole?: string;
|
|
8
9
|
getRole?: (data: {
|
|
9
10
|
user: User & Record<string, any>;
|
|
10
11
|
userInfo: Record<string, any>;
|
|
11
12
|
token?: OAuth2Tokens;
|
|
12
13
|
provider: SSOProvider<SSOOptions>;
|
|
13
|
-
}) => Promise<
|
|
14
|
+
}) => Promise<string>;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export interface AssignOrganizationFromProviderOptions {
|
|
@@ -39,11 +40,11 @@ export async function assignOrganizationFromProvider(
|
|
|
39
40
|
return;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
const
|
|
43
|
-
(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (!
|
|
43
|
+
const hasOrganizationPlugin =
|
|
44
|
+
ctx.context.options.plugins?.some(
|
|
45
|
+
(plugin) => plugin.id === "organization",
|
|
46
|
+
) ?? false;
|
|
47
|
+
if (!hasOrganizationPlugin) {
|
|
47
48
|
return;
|
|
48
49
|
}
|
|
49
50
|
|
|
@@ -105,11 +106,11 @@ export async function assignOrganizationByDomain(
|
|
|
105
106
|
return;
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
const
|
|
109
|
-
(
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (!
|
|
109
|
+
const hasOrganizationPlugin =
|
|
110
|
+
ctx.context.options.plugins?.some(
|
|
111
|
+
(plugin) => plugin.id === "organization",
|
|
112
|
+
) ?? false;
|
|
113
|
+
if (!hasOrganizationPlugin) {
|
|
113
114
|
return;
|
|
114
115
|
}
|
|
115
116
|
|
|
@@ -118,6 +119,8 @@ export async function assignOrganizationByDomain(
|
|
|
118
119
|
return;
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
// Support comma-separated domains for multi-domain SSO
|
|
123
|
+
// First try exact match (fast path)
|
|
121
124
|
const whereClause: { field: string; value: string | boolean }[] = [
|
|
122
125
|
{ field: "domain", value: domain },
|
|
123
126
|
];
|
|
@@ -126,13 +129,25 @@ export async function assignOrganizationByDomain(
|
|
|
126
129
|
whereClause.push({ field: "domainVerified", value: true });
|
|
127
130
|
}
|
|
128
131
|
|
|
129
|
-
|
|
130
|
-
SSOProvider<SSOOptions>
|
|
131
|
-
>({
|
|
132
|
+
let ssoProvider = await ctx.context.adapter.findOne<SSOProvider<SSOOptions>>({
|
|
132
133
|
model: "ssoProvider",
|
|
133
134
|
where: whereClause,
|
|
134
135
|
});
|
|
135
136
|
|
|
137
|
+
// If not found, search all providers for comma-separated domain match
|
|
138
|
+
if (!ssoProvider) {
|
|
139
|
+
const allProviders = await ctx.context.adapter.findMany<
|
|
140
|
+
SSOProvider<SSOOptions>
|
|
141
|
+
>({
|
|
142
|
+
model: "ssoProvider",
|
|
143
|
+
where: domainVerification?.enabled
|
|
144
|
+
? [{ field: "domainVerified", value: true }]
|
|
145
|
+
: [],
|
|
146
|
+
});
|
|
147
|
+
ssoProvider =
|
|
148
|
+
allProviders.find((p) => domainMatches(domain, p.domain)) ?? null;
|
|
149
|
+
}
|
|
150
|
+
|
|
136
151
|
if (!ssoProvider || !ssoProvider.organizationId) {
|
|
137
152
|
return;
|
|
138
153
|
}
|
package/src/oidc.test.ts
CHANGED
|
@@ -393,9 +393,7 @@ describe("SSO disable implicit sign in", async () => {
|
|
|
393
393
|
"redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fsso%2Fcallback%2Ftest",
|
|
394
394
|
);
|
|
395
395
|
const { callbackURL } = await simulateOAuthFlow(res.url, headers);
|
|
396
|
-
expect(callbackURL).toContain(
|
|
397
|
-
"/api/auth/error/error?error=signup disabled",
|
|
398
|
-
);
|
|
396
|
+
expect(callbackURL).toContain("/api/auth/error?error=signup disabled");
|
|
399
397
|
});
|
|
400
398
|
|
|
401
399
|
it("should create user with SSO provider when sign ups are disabled but sign up is requested", async () => {
|