@better-auth/sso 1.4.17 → 1.5.0-beta.10
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 +13 -9
- package/LICENSE.md +15 -12
- 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-CBBJTszO.d.mts} +395 -16
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1130 -598
- package/dist/index.mjs.map +1 -0
- package/package.json +16 -14
- package/src/client.ts +5 -1
- package/src/index.ts +49 -6
- package/src/linking/org-assignment.test.ts +1 -1
- package/src/linking/org-assignment.ts +20 -13
- package/src/oidc.test.ts +112 -0
- package/src/providers.test.ts +1326 -0
- package/src/routes/providers.ts +565 -0
- package/src/routes/schemas.ts +96 -0
- package/src/routes/sso.ts +220 -50
- package/src/saml-state.ts +78 -0
- package/src/saml.test.ts +1588 -233
- package/src/types.ts +8 -0
- package/src/utils.test.ts +103 -0
- package/src/utils.ts +45 -5
- package/tsconfig.json +3 -0
- 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
|
+
"version": "1.5.0-beta.10",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.mjs",
|
|
7
7
|
"types": "dist/index.d.mts",
|
|
@@ -52,26 +52,28 @@
|
|
|
52
52
|
}
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@better-auth/utils": "0.3.
|
|
55
|
+
"@better-auth/utils": "0.3.1",
|
|
56
56
|
"@better-fetch/fetch": "1.1.21",
|
|
57
|
-
"fast-xml-parser": "^5.
|
|
57
|
+
"fast-xml-parser": "^5.3.3",
|
|
58
58
|
"jose": "^6.1.0",
|
|
59
|
-
"samlify": "^2.10.
|
|
60
|
-
"zod": "^4.3.
|
|
59
|
+
"samlify": "^2.10.2",
|
|
60
|
+
"zod": "^4.3.6"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
63
|
"@types/body-parser": "^1.19.6",
|
|
64
|
-
"@types/express": "^5.0.
|
|
65
|
-
"better-call": "1.
|
|
66
|
-
"body-parser": "^2.2.
|
|
67
|
-
"express": "^5.1
|
|
68
|
-
"oauth2-mock-server": "^8.2.
|
|
69
|
-
"tsdown": "^0.
|
|
70
|
-
"better-auth": "1.
|
|
64
|
+
"@types/express": "^5.0.6",
|
|
65
|
+
"better-call": "1.2.0",
|
|
66
|
+
"body-parser": "^2.2.2",
|
|
67
|
+
"express": "^5.2.1",
|
|
68
|
+
"oauth2-mock-server": "^8.2.1",
|
|
69
|
+
"tsdown": "^0.20.1",
|
|
70
|
+
"@better-auth/core": "1.5.0-beta.10",
|
|
71
|
+
"better-auth": "1.5.0-beta.10"
|
|
71
72
|
},
|
|
72
73
|
"peerDependencies": {
|
|
73
|
-
"@better-auth/utils": "0.3.
|
|
74
|
-
"better-auth": "1.
|
|
74
|
+
"@better-auth/utils": "0.3.1",
|
|
75
|
+
"@better-auth/core": "1.5.0-beta.10",
|
|
76
|
+
"better-auth": "1.5.0-beta.10"
|
|
75
77
|
},
|
|
76
78
|
"scripts": {
|
|
77
79
|
"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
|
};
|
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,
|
|
@@ -41,6 +47,14 @@ import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "./types";
|
|
|
41
47
|
|
|
42
48
|
export type { SAMLConfig, OIDCConfig, SSOOptions, SSOProvider };
|
|
43
49
|
|
|
50
|
+
declare module "@better-auth/core" {
|
|
51
|
+
interface BetterAuthPluginRegistry<AuthOptions, Options> {
|
|
52
|
+
sso: {
|
|
53
|
+
creator: typeof sso;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
44
58
|
export {
|
|
45
59
|
computeDiscoveryUrl,
|
|
46
60
|
type DiscoverOIDCConfigParams,
|
|
@@ -84,6 +98,10 @@ type SSOEndpoints<O extends SSOOptions> = {
|
|
|
84
98
|
callbackSSO: ReturnType<typeof callbackSSO>;
|
|
85
99
|
callbackSSOSAML: ReturnType<typeof callbackSSOSAML>;
|
|
86
100
|
acsEndpoint: ReturnType<typeof acsEndpoint>;
|
|
101
|
+
listSSOProviders: ReturnType<typeof listSSOProviders>;
|
|
102
|
+
getSSOProvider: ReturnType<typeof getSSOProvider>;
|
|
103
|
+
updateSSOProvider: ReturnType<typeof updateSSOProvider>;
|
|
104
|
+
deleteSSOProvider: ReturnType<typeof deleteSSOProvider>;
|
|
87
105
|
};
|
|
88
106
|
|
|
89
107
|
export type SSOPlugin<O extends SSOOptions> = {
|
|
@@ -94,6 +112,16 @@ export type SSOPlugin<O extends SSOOptions> = {
|
|
|
94
112
|
: {});
|
|
95
113
|
};
|
|
96
114
|
|
|
115
|
+
/**
|
|
116
|
+
* SAML endpoint paths that should skip origin check validation.
|
|
117
|
+
* These endpoints receive POST requests from external Identity Providers,
|
|
118
|
+
* which won't have a matching Origin header.
|
|
119
|
+
*/
|
|
120
|
+
const SAML_SKIP_ORIGIN_CHECK_PATHS = [
|
|
121
|
+
"/sso/saml2/callback", // SP-initiated SSO callback (prefix matches /callback/:providerId)
|
|
122
|
+
"/sso/saml2/sp/acs", // IdP-initiated SSO ACS (prefix matches /sp/acs/:providerId)
|
|
123
|
+
];
|
|
124
|
+
|
|
97
125
|
export function sso<
|
|
98
126
|
O extends SSOOptions & {
|
|
99
127
|
domainVerification?: { enabled: true };
|
|
@@ -103,7 +131,7 @@ export function sso<
|
|
|
103
131
|
): {
|
|
104
132
|
id: "sso";
|
|
105
133
|
endpoints: SSOEndpoints<O> & DomainVerificationEndpoints;
|
|
106
|
-
schema:
|
|
134
|
+
schema: NonNullable<BetterAuthPlugin["schema"]>;
|
|
107
135
|
options: O;
|
|
108
136
|
};
|
|
109
137
|
export function sso<O extends SSOOptions>(
|
|
@@ -113,7 +141,9 @@ export function sso<O extends SSOOptions>(
|
|
|
113
141
|
endpoints: SSOEndpoints<O>;
|
|
114
142
|
};
|
|
115
143
|
|
|
116
|
-
export function sso<O extends SSOOptions>(
|
|
144
|
+
export function sso<O extends SSOOptions>(
|
|
145
|
+
options?: O | undefined,
|
|
146
|
+
): BetterAuthPlugin {
|
|
117
147
|
const optionsWithStore = options as O;
|
|
118
148
|
|
|
119
149
|
let endpoints = {
|
|
@@ -123,6 +153,10 @@ export function sso<O extends SSOOptions>(options?: O | undefined): any {
|
|
|
123
153
|
callbackSSO: callbackSSO(optionsWithStore),
|
|
124
154
|
callbackSSOSAML: callbackSSOSAML(optionsWithStore),
|
|
125
155
|
acsEndpoint: acsEndpoint(optionsWithStore),
|
|
156
|
+
listSSOProviders: listSSOProviders(),
|
|
157
|
+
getSSOProvider: getSSOProvider(),
|
|
158
|
+
updateSSOProvider: updateSSOProvider(optionsWithStore),
|
|
159
|
+
deleteSSOProvider: deleteSSOProvider(),
|
|
126
160
|
};
|
|
127
161
|
|
|
128
162
|
if (options?.domainVerification?.enabled) {
|
|
@@ -139,6 +173,18 @@ export function sso<O extends SSOOptions>(options?: O | undefined): any {
|
|
|
139
173
|
|
|
140
174
|
return {
|
|
141
175
|
id: "sso",
|
|
176
|
+
init(ctx) {
|
|
177
|
+
const existing = ctx.skipOriginCheck;
|
|
178
|
+
if (existing === true) {
|
|
179
|
+
return {};
|
|
180
|
+
}
|
|
181
|
+
const existingPaths = Array.isArray(existing) ? existing : [];
|
|
182
|
+
return {
|
|
183
|
+
context: {
|
|
184
|
+
skipOriginCheck: [...existingPaths, ...SAML_SKIP_ORIGIN_CHECK_PATHS],
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
},
|
|
142
188
|
endpoints,
|
|
143
189
|
hooks: {
|
|
144
190
|
after: [
|
|
@@ -152,10 +198,7 @@ export function sso<O extends SSOOptions>(options?: O | undefined): any {
|
|
|
152
198
|
return;
|
|
153
199
|
}
|
|
154
200
|
|
|
155
|
-
|
|
156
|
-
(plugin: { id: string }) => plugin.id === "organization",
|
|
157
|
-
);
|
|
158
|
-
if (!isOrgPluginEnabled) {
|
|
201
|
+
if (!ctx.context.hasPlugin("organization")) {
|
|
159
202
|
return;
|
|
160
203
|
}
|
|
161
204
|
|
|
@@ -56,7 +56,7 @@ describe("assignOrganizationByDomain", () => {
|
|
|
56
56
|
|
|
57
57
|
const createContext = async () => {
|
|
58
58
|
const context = await auth.$context;
|
|
59
|
-
return { context } as Partial<GenericEndpointContext>;
|
|
59
|
+
return { context } as unknown as Partial<GenericEndpointContext>;
|
|
60
60
|
};
|
|
61
61
|
|
|
62
62
|
return { auth, data, createContext };
|
|
@@ -1,5 +1,6 @@
|
|
|
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 {
|
|
@@ -39,11 +40,7 @@ export async function assignOrganizationFromProvider(
|
|
|
39
40
|
return;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
(plugin) => plugin.id === "organization",
|
|
44
|
-
);
|
|
45
|
-
|
|
46
|
-
if (!isOrgPluginEnabled) {
|
|
43
|
+
if (!ctx.context.hasPlugin("organization")) {
|
|
47
44
|
return;
|
|
48
45
|
}
|
|
49
46
|
|
|
@@ -105,11 +102,7 @@ export async function assignOrganizationByDomain(
|
|
|
105
102
|
return;
|
|
106
103
|
}
|
|
107
104
|
|
|
108
|
-
|
|
109
|
-
(plugin) => plugin.id === "organization",
|
|
110
|
-
);
|
|
111
|
-
|
|
112
|
-
if (!isOrgPluginEnabled) {
|
|
105
|
+
if (!ctx.context.hasPlugin("organization")) {
|
|
113
106
|
return;
|
|
114
107
|
}
|
|
115
108
|
|
|
@@ -118,6 +111,8 @@ export async function assignOrganizationByDomain(
|
|
|
118
111
|
return;
|
|
119
112
|
}
|
|
120
113
|
|
|
114
|
+
// Support comma-separated domains for multi-domain SSO
|
|
115
|
+
// First try exact match (fast path)
|
|
121
116
|
const whereClause: { field: string; value: string | boolean }[] = [
|
|
122
117
|
{ field: "domain", value: domain },
|
|
123
118
|
];
|
|
@@ -126,13 +121,25 @@ export async function assignOrganizationByDomain(
|
|
|
126
121
|
whereClause.push({ field: "domainVerified", value: true });
|
|
127
122
|
}
|
|
128
123
|
|
|
129
|
-
|
|
130
|
-
SSOProvider<SSOOptions>
|
|
131
|
-
>({
|
|
124
|
+
let ssoProvider = await ctx.context.adapter.findOne<SSOProvider<SSOOptions>>({
|
|
132
125
|
model: "ssoProvider",
|
|
133
126
|
where: whereClause,
|
|
134
127
|
});
|
|
135
128
|
|
|
129
|
+
// If not found, search all providers for comma-separated domain match
|
|
130
|
+
if (!ssoProvider) {
|
|
131
|
+
const allProviders = await ctx.context.adapter.findMany<
|
|
132
|
+
SSOProvider<SSOOptions>
|
|
133
|
+
>({
|
|
134
|
+
model: "ssoProvider",
|
|
135
|
+
where: domainVerification?.enabled
|
|
136
|
+
? [{ field: "domainVerified", value: true }]
|
|
137
|
+
: [],
|
|
138
|
+
});
|
|
139
|
+
ssoProvider =
|
|
140
|
+
allProviders.find((p) => domainMatches(domain, p.domain)) ?? null;
|
|
141
|
+
}
|
|
142
|
+
|
|
136
143
|
if (!ssoProvider || !ssoProvider.organizationId) {
|
|
137
144
|
return;
|
|
138
145
|
}
|
package/src/oidc.test.ts
CHANGED
|
@@ -253,6 +253,118 @@ describe("SSO", async () => {
|
|
|
253
253
|
const { callbackURL } = await simulateOAuthFlow(res.url, headers);
|
|
254
254
|
expect(callbackURL).toContain("/dashboard");
|
|
255
255
|
});
|
|
256
|
+
|
|
257
|
+
it("should normalize email to lowercase in OIDC authentication", async () => {
|
|
258
|
+
const { headers } = await signInWithTestUser();
|
|
259
|
+
|
|
260
|
+
// Register a new provider for this test
|
|
261
|
+
await auth.api.registerSSOProvider({
|
|
262
|
+
body: {
|
|
263
|
+
providerId: "email-case-oidc-provider",
|
|
264
|
+
issuer: server.issuer.url!,
|
|
265
|
+
domain: "email-case-test.com",
|
|
266
|
+
oidcConfig: {
|
|
267
|
+
clientId: "email-case-test-client",
|
|
268
|
+
clientSecret: "test-client-secret",
|
|
269
|
+
discoveryEndpoint: `${server.issuer.url!}/.well-known/openid-configuration`,
|
|
270
|
+
pkce: false,
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
headers,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Store original listeners and set up mixed-case email
|
|
277
|
+
const originalUserinfoListeners =
|
|
278
|
+
server.service.listeners("beforeUserinfo");
|
|
279
|
+
const originalTokenListeners =
|
|
280
|
+
server.service.listeners("beforeTokenSigning");
|
|
281
|
+
|
|
282
|
+
server.service.removeAllListeners("beforeUserinfo");
|
|
283
|
+
server.service.removeAllListeners("beforeTokenSigning");
|
|
284
|
+
|
|
285
|
+
const mixedCaseEmail = "OIDCUser@Example.COM";
|
|
286
|
+
|
|
287
|
+
server.service.on("beforeUserinfo", (userInfoResponse) => {
|
|
288
|
+
userInfoResponse.body = {
|
|
289
|
+
email: mixedCaseEmail,
|
|
290
|
+
name: "OIDC Test User",
|
|
291
|
+
sub: "oidc-email-case-test-user",
|
|
292
|
+
picture: "https://test.com/picture.png",
|
|
293
|
+
email_verified: true,
|
|
294
|
+
};
|
|
295
|
+
userInfoResponse.statusCode = 200;
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
server.service.on("beforeTokenSigning", (token) => {
|
|
299
|
+
token.payload.email = mixedCaseEmail;
|
|
300
|
+
token.payload.email_verified = true;
|
|
301
|
+
token.payload.name = "OIDC Test User";
|
|
302
|
+
token.payload.sub = "oidc-email-case-test-user";
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
// First sign in - should create user with lowercase email
|
|
307
|
+
const signInHeaders1 = new Headers();
|
|
308
|
+
const res1 = await authClient.signIn.sso({
|
|
309
|
+
email: `user@email-case-test.com`,
|
|
310
|
+
callbackURL: "/dashboard",
|
|
311
|
+
fetchOptions: {
|
|
312
|
+
throw: true,
|
|
313
|
+
onSuccess: cookieSetter(signInHeaders1),
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const { callbackURL: callbackURL1, headers: sessionHeaders1 } =
|
|
318
|
+
await simulateOAuthFlow(res1.url, signInHeaders1);
|
|
319
|
+
expect(callbackURL1).toContain("/dashboard");
|
|
320
|
+
|
|
321
|
+
// Get session and verify email is lowercase
|
|
322
|
+
const session1 = await authClient.getSession({
|
|
323
|
+
fetchOptions: {
|
|
324
|
+
headers: sessionHeaders1,
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
expect(session1.data?.user.email).toBe("oidcuser@example.com");
|
|
329
|
+
const firstUserId = session1.data?.user.id;
|
|
330
|
+
expect(firstUserId).toBeDefined();
|
|
331
|
+
|
|
332
|
+
// Second sign in with same mixed-case email - should find existing user
|
|
333
|
+
const signInHeaders2 = new Headers();
|
|
334
|
+
const res2 = await authClient.signIn.sso({
|
|
335
|
+
email: `user@email-case-test.com`,
|
|
336
|
+
callbackURL: "/dashboard",
|
|
337
|
+
fetchOptions: {
|
|
338
|
+
throw: true,
|
|
339
|
+
onSuccess: cookieSetter(signInHeaders2),
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const { callbackURL: callbackURL2, headers: sessionHeaders2 } =
|
|
344
|
+
await simulateOAuthFlow(res2.url, signInHeaders2);
|
|
345
|
+
expect(callbackURL2).toContain("/dashboard");
|
|
346
|
+
|
|
347
|
+
// Verify same user is returned
|
|
348
|
+
const session2 = await authClient.getSession({
|
|
349
|
+
fetchOptions: {
|
|
350
|
+
headers: sessionHeaders2,
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
expect(session2.data?.user.id).toBe(firstUserId);
|
|
355
|
+
expect(session2.data?.user.email).toBe("oidcuser@example.com");
|
|
356
|
+
} finally {
|
|
357
|
+
// Restore original listeners
|
|
358
|
+
server.service.removeAllListeners("beforeUserinfo");
|
|
359
|
+
server.service.removeAllListeners("beforeTokenSigning");
|
|
360
|
+
for (const listener of originalUserinfoListeners) {
|
|
361
|
+
server.service.on("beforeUserinfo", listener);
|
|
362
|
+
}
|
|
363
|
+
for (const listener of originalTokenListeners) {
|
|
364
|
+
server.service.on("beforeTokenSigning", listener);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
});
|
|
256
368
|
});
|
|
257
369
|
|
|
258
370
|
describe("SSO disable implicit sign in", async () => {
|