@better-auth/sso 1.5.0-beta.1 → 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/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.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,29 +52,32 @@
52
52
  }
53
53
  },
54
54
  "dependencies": {
55
- "@better-auth/utils": "0.3.0",
55
+ "@better-auth/utils": "0.3.1",
56
56
  "@better-fetch/fetch": "1.1.21",
57
- "fast-xml-parser": "^5.2.5",
57
+ "fast-xml-parser": "^5.3.3",
58
58
  "jose": "^6.1.0",
59
- "samlify": "^2.10.1",
60
- "zod": "^4.1.12"
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.5",
65
- "better-call": "1.1.7",
66
- "body-parser": "^2.2.1",
67
- "express": "^5.1.0",
68
- "oauth2-mock-server": "^8.2.0",
69
- "tsdown": "^0.17.2",
70
- "better-auth": "1.5.0-beta.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": "1.5.0-beta.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"
74
77
  },
75
78
  "scripts": {
76
79
  "test": "vitest",
77
- "coverage": "vitest run --coverage",
80
+ "coverage": "vitest run --coverage --coverage.provider=istanbul",
78
81
  "lint:package": "publint run --strict",
79
82
  "lint:types": "attw --profile esm-only --pack .",
80
83
  "build": "tsdown",
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/constants.ts CHANGED
@@ -40,3 +40,19 @@ export const DEFAULT_ASSERTION_TTL_MS = 15 * 60 * 1000;
40
40
  * - Distributed systems across timezones
41
41
  */
42
42
  export const DEFAULT_CLOCK_SKEW_MS = 5 * 60 * 1000;
43
+
44
+ // ============================================================================
45
+ // Size Limits (DoS Protection)
46
+ // ============================================================================
47
+
48
+ /**
49
+ * Default maximum size for SAML responses (256 KB).
50
+ * Protects against memory exhaustion from oversized SAML payloads.
51
+ */
52
+ export const DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024;
53
+
54
+ /**
55
+ * Default maximum size for IdP metadata (100 KB).
56
+ * Protects against oversized metadata documents.
57
+ */
58
+ export const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
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,
@@ -16,6 +22,12 @@ import {
16
22
  spMetadata,
17
23
  } from "./routes/sso";
18
24
 
25
+ export {
26
+ DEFAULT_CLOCK_SKEW_MS,
27
+ DEFAULT_MAX_SAML_METADATA_SIZE,
28
+ DEFAULT_MAX_SAML_RESPONSE_SIZE,
29
+ } from "./constants";
30
+
19
31
  export {
20
32
  type SAMLConditions,
21
33
  type TimestampValidationOptions,
@@ -35,6 +47,14 @@ import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "./types";
35
47
 
36
48
  export type { SAMLConfig, OIDCConfig, SSOOptions, SSOProvider };
37
49
 
50
+ declare module "@better-auth/core" {
51
+ interface BetterAuthPluginRegistry<AuthOptions, Options> {
52
+ sso: {
53
+ creator: typeof sso;
54
+ };
55
+ }
56
+ }
57
+
38
58
  export {
39
59
  computeDiscoveryUrl,
40
60
  type DiscoverOIDCConfigParams,
@@ -78,6 +98,10 @@ type SSOEndpoints<O extends SSOOptions> = {
78
98
  callbackSSO: ReturnType<typeof callbackSSO>;
79
99
  callbackSSOSAML: ReturnType<typeof callbackSSOSAML>;
80
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>;
81
105
  };
82
106
 
83
107
  export type SSOPlugin<O extends SSOOptions> = {
@@ -88,6 +112,16 @@ export type SSOPlugin<O extends SSOOptions> = {
88
112
  : {});
89
113
  };
90
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
+
91
125
  export function sso<
92
126
  O extends SSOOptions & {
93
127
  domainVerification?: { enabled: true };
@@ -97,7 +131,7 @@ export function sso<
97
131
  ): {
98
132
  id: "sso";
99
133
  endpoints: SSOEndpoints<O> & DomainVerificationEndpoints;
100
- schema: any;
134
+ schema: NonNullable<BetterAuthPlugin["schema"]>;
101
135
  options: O;
102
136
  };
103
137
  export function sso<O extends SSOOptions>(
@@ -107,7 +141,9 @@ export function sso<O extends SSOOptions>(
107
141
  endpoints: SSOEndpoints<O>;
108
142
  };
109
143
 
110
- export function sso<O extends SSOOptions>(options?: O | undefined): any {
144
+ export function sso<O extends SSOOptions>(
145
+ options?: O | undefined,
146
+ ): BetterAuthPlugin {
111
147
  const optionsWithStore = options as O;
112
148
 
113
149
  let endpoints = {
@@ -117,6 +153,10 @@ export function sso<O extends SSOOptions>(options?: O | undefined): any {
117
153
  callbackSSO: callbackSSO(optionsWithStore),
118
154
  callbackSSOSAML: callbackSSOSAML(optionsWithStore),
119
155
  acsEndpoint: acsEndpoint(optionsWithStore),
156
+ listSSOProviders: listSSOProviders(),
157
+ getSSOProvider: getSSOProvider(),
158
+ updateSSOProvider: updateSSOProvider(optionsWithStore),
159
+ deleteSSOProvider: deleteSSOProvider(),
120
160
  };
121
161
 
122
162
  if (options?.domainVerification?.enabled) {
@@ -133,6 +173,18 @@ export function sso<O extends SSOOptions>(options?: O | undefined): any {
133
173
 
134
174
  return {
135
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
+ },
136
188
  endpoints,
137
189
  hooks: {
138
190
  after: [
@@ -146,10 +198,7 @@ export function sso<O extends SSOOptions>(options?: O | undefined): any {
146
198
  return;
147
199
  }
148
200
 
149
- const isOrgPluginEnabled = ctx.context.options.plugins?.find(
150
- (plugin: { id: string }) => plugin.id === "organization",
151
- );
152
- if (!isOrgPluginEnabled) {
201
+ if (!ctx.context.hasPlugin("organization")) {
153
202
  return;
154
203
  }
155
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
- const isOrgPluginEnabled = ctx.context.options.plugins?.find(
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
- const isOrgPluginEnabled = ctx.context.options.plugins?.find(
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
- const ssoProvider = await ctx.context.adapter.findOne<
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
@@ -7,7 +7,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
7
7
  import { sso } from ".";
8
8
  import { ssoClient } from "./client";
9
9
 
10
- let server = new OAuth2Server();
10
+ const server = new OAuth2Server();
11
11
 
12
12
  describe("SSO", async () => {
13
13
  const { auth, signInWithTestUser, customFetchImpl, cookieSetter } =
@@ -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 () => {