@better-auth/sso 1.5.0-beta.18 → 1.5.0-beta.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/src/types.ts DELETED
@@ -1,416 +0,0 @@
1
- import type { Awaitable, OAuth2Tokens, User } from "better-auth";
2
- import type { AlgorithmValidationOptions } from "./saml/algorithms";
3
-
4
- export interface OIDCMapping {
5
- id?: string | undefined;
6
- email?: string | undefined;
7
- emailVerified?: string | undefined;
8
- name?: string | undefined;
9
- image?: string | undefined;
10
- extraFields?: Record<string, string> | undefined;
11
- }
12
-
13
- export interface SAMLMapping {
14
- id?: string | undefined;
15
- email?: string | undefined;
16
- emailVerified?: string | undefined;
17
- name?: string | undefined;
18
- firstName?: string | undefined;
19
- lastName?: string | undefined;
20
- extraFields?: Record<string, string> | undefined;
21
- }
22
-
23
- export interface OIDCConfig {
24
- issuer: string;
25
- pkce: boolean;
26
- clientId: string;
27
- clientSecret: string;
28
- authorizationEndpoint?: string | undefined;
29
- discoveryEndpoint: string;
30
- userInfoEndpoint?: string | undefined;
31
- scopes?: string[] | undefined;
32
- overrideUserInfo?: boolean | undefined;
33
- tokenEndpoint?: string | undefined;
34
- tokenEndpointAuthentication?:
35
- | ("client_secret_post" | "client_secret_basic")
36
- | undefined;
37
- jwksEndpoint?: string | undefined;
38
- mapping?: OIDCMapping | undefined;
39
- }
40
-
41
- export interface SAMLConfig {
42
- issuer: string;
43
- entryPoint: string;
44
- cert: string;
45
- callbackUrl: string;
46
- audience?: string | undefined;
47
- idpMetadata?:
48
- | {
49
- metadata?: string;
50
- entityID?: string;
51
- entityURL?: string;
52
- redirectURL?: string;
53
- cert?: string;
54
- privateKey?: string;
55
- privateKeyPass?: string;
56
- isAssertionEncrypted?: boolean;
57
- encPrivateKey?: string;
58
- encPrivateKeyPass?: string;
59
- singleSignOnService?: Array<{
60
- Binding: string;
61
- Location: string;
62
- }>;
63
- singleLogoutService?: Array<{
64
- Binding: string;
65
- Location: string;
66
- }>;
67
- }
68
- | undefined;
69
- spMetadata: {
70
- metadata?: string | undefined;
71
- entityID?: string | undefined;
72
- binding?: string | undefined;
73
- privateKey?: string | undefined;
74
- privateKeyPass?: string | undefined;
75
- isAssertionEncrypted?: boolean | undefined;
76
- encPrivateKey?: string | undefined;
77
- encPrivateKeyPass?: string | undefined;
78
- };
79
- wantAssertionsSigned?: boolean | undefined;
80
- authnRequestsSigned?: boolean | undefined;
81
- signatureAlgorithm?: string | undefined;
82
- digestAlgorithm?: string | undefined;
83
- identifierFormat?: string | undefined;
84
- privateKey?: string | undefined;
85
- decryptionPvk?: string | undefined;
86
- additionalParams?: Record<string, any> | undefined;
87
- mapping?: SAMLMapping | undefined;
88
- }
89
-
90
- /** Session data stored during SAML login for Single Logout */
91
- export interface SAMLSessionRecord {
92
- sessionId: string;
93
- providerId: string;
94
- nameID: string;
95
- sessionIndex?: string;
96
- }
97
-
98
- /** Parsed SAML assertion extract from samlify */
99
- export interface SAMLAssertionExtract {
100
- nameID?: string;
101
- sessionIndex?: string;
102
- inResponseTo?: string;
103
- conditions?: {
104
- notBefore?: string;
105
- notOnOrAfter?: string;
106
- };
107
- }
108
-
109
- type BaseSSOProvider = {
110
- issuer: string;
111
- oidcConfig?: OIDCConfig | undefined;
112
- samlConfig?: SAMLConfig | undefined;
113
- userId: string;
114
- providerId: string;
115
- organizationId?: string | undefined;
116
- domain: string;
117
- };
118
-
119
- export type SSOProvider<O extends SSOOptions> =
120
- O["domainVerification"] extends { enabled: true }
121
- ? {
122
- domainVerified: boolean;
123
- } & BaseSSOProvider
124
- : BaseSSOProvider;
125
-
126
- export interface SSOOptions {
127
- /**
128
- * custom function to provision a user when they sign in with an SSO provider.
129
- */
130
- provisionUser?:
131
- | ((data: {
132
- /**
133
- * The user object from the database
134
- */
135
- user: User & Record<string, any>;
136
- /**
137
- * The user info object from the provider
138
- */
139
- userInfo: Record<string, any>;
140
- /**
141
- * The OAuth2 tokens from the provider
142
- */
143
- token?: OAuth2Tokens;
144
- /**
145
- * The SSO provider
146
- */
147
- provider: SSOProvider<SSOOptions>;
148
- }) => Awaitable<void>)
149
- | undefined;
150
- /**
151
- * Organization provisioning options
152
- */
153
- organizationProvisioning?:
154
- | {
155
- disabled?: boolean;
156
- defaultRole?: "member" | "admin";
157
- getRole?: (data: {
158
- /**
159
- * The user object from the database
160
- */
161
- user: User & Record<string, any>;
162
- /**
163
- * The user info object from the provider
164
- */
165
- userInfo: Record<string, any>;
166
- /**
167
- * The OAuth2 tokens from the provider
168
- */
169
- token?: OAuth2Tokens;
170
- /**
171
- * The SSO provider
172
- */
173
- provider: SSOProvider<SSOOptions>;
174
- }) => Promise<"member" | "admin">;
175
- }
176
- | undefined;
177
- /**
178
- * Default SSO provider configurations for testing.
179
- * These will take the precedence over the database providers.
180
- */
181
- defaultSSO?:
182
- | Array<{
183
- /**
184
- * The domain to match for this default provider.
185
- * This is only used to match incoming requests to this default provider.
186
- */
187
- domain: string;
188
- /**
189
- * The provider ID to use
190
- */
191
- providerId: string;
192
- /**
193
- * SAML configuration
194
- */
195
- samlConfig?: SAMLConfig;
196
- /**
197
- * OIDC configuration
198
- */
199
- oidcConfig?: OIDCConfig;
200
- }>
201
- | undefined;
202
- /**
203
- * Override user info with the provider info.
204
- * @default false
205
- */
206
- defaultOverrideUserInfo?: boolean | undefined;
207
- /**
208
- * Disable implicit sign up for new users. When set to true for the provider,
209
- * sign-in need to be called with with requestSignUp as true to create new users.
210
- */
211
- disableImplicitSignUp?: boolean | undefined;
212
- /**
213
- * The model name for the SSO provider table. Defaults to "ssoProvider".
214
- */
215
- modelName?: string;
216
- /**
217
- * Map fields
218
- *
219
- * @example
220
- * ```ts
221
- * {
222
- * samlConfig: "saml_config"
223
- * }
224
- * ```
225
- */
226
- fields?: {
227
- issuer?: string | undefined;
228
- oidcConfig?: string | undefined;
229
- samlConfig?: string | undefined;
230
- userId?: string | undefined;
231
- providerId?: string | undefined;
232
- organizationId?: string | undefined;
233
- domain?: string | undefined;
234
- };
235
- /**
236
- * Configure the maximum number of SSO providers a user can register.
237
- * You can also pass a function that returns a number.
238
- * Set to 0 to disable SSO provider registration.
239
- *
240
- * @example
241
- * ```ts
242
- * providersLimit: async (user) => {
243
- * const plan = await getUserPlan(user);
244
- * return plan.name === "pro" ? 10 : 1;
245
- * }
246
- * ```
247
- * @default 10
248
- */
249
- providersLimit?: (number | ((user: User) => Awaitable<number>)) | undefined;
250
- /**
251
- * Trust the email verified flag from the provider.
252
- *
253
- * ⚠️ Use this with caution — it can lead to account takeover if misused. Only enable it if users **cannot freely register new providers**. You can
254
- * prevent that by using `disabledPaths` or other safeguards to block provider registration from the client.
255
- *
256
- * If you want to allow account linking for specific trusted providers, enable the `accountLinking` option in your auth config and specify those
257
- * providers in the `trustedProviders` list.
258
- *
259
- * @default false
260
- *
261
- * @deprecated This option is discouraged for new projects. Relying on provider-level `email_verified` is a weaker
262
- * trust signal compared to using `trustedProviders` in `accountLinking` or enabling `domainVerification` for SSO.
263
- * Existing configurations will continue to work, but new integrations should use explicit trust mechanisms.
264
- * This option may be removed in a future major version.
265
- */
266
- trustEmailVerified?: boolean | undefined;
267
- /**
268
- * Enable domain verification on SSO providers
269
- *
270
- * When this option is enabled, new SSO providers will require the associated domain to be verified by the owner
271
- * prior to allowing sign-ins.
272
- */
273
- domainVerification?: {
274
- /**
275
- * Enables or disables the domain verification feature
276
- */
277
- enabled?: boolean;
278
- /**
279
- * Prefix used to generate the domain verification token.
280
- * An underscore is automatically prepended to follow DNS
281
- * infrastructure subdomain conventions (RFC 8552), so do
282
- * not include a leading underscore.
283
- *
284
- * @default "better-auth-token"
285
- */
286
- tokenPrefix?: string;
287
- };
288
- /**
289
- * A shared redirect URI used by all OIDC providers instead of
290
- * per-provider callback URLs. Can be a path or a full URL.
291
- */
292
- redirectURI?: string;
293
- /**
294
- * SAML security options for AuthnRequest/InResponseTo validation.
295
- * This prevents unsolicited responses, replay attacks, and cross-provider injection.
296
- */
297
- saml?: {
298
- /**
299
- * Enable InResponseTo validation for SP-initiated SAML flows.
300
- * When enabled, AuthnRequest IDs are tracked and validated against SAML responses.
301
- *
302
- * Storage behavior:
303
- * - Uses `secondaryStorage` (e.g., Redis) if configured in your auth options
304
- * - Falls back to the verification table in the database otherwise
305
- *
306
- * This works correctly in serverless environments without any additional configuration.
307
- *
308
- * @default false
309
- */
310
- enableInResponseToValidation?: boolean;
311
- /**
312
- * Allow IdP-initiated SSO (unsolicited SAML responses).
313
- * When true, responses without InResponseTo are accepted.
314
- * When false, all responses must correlate to a stored AuthnRequest.
315
- *
316
- * Only applies when InResponseTo validation is enabled.
317
- *
318
- * @default true
319
- */
320
- allowIdpInitiated?: boolean;
321
- /**
322
- * TTL for AuthnRequest records in milliseconds.
323
- * Requests older than this will be rejected.
324
- *
325
- * Only applies when InResponseTo validation is enabled.
326
- *
327
- * @default 300000 (5 minutes)
328
- */
329
- requestTTL?: number;
330
- /**
331
- * Clock skew tolerance for SAML assertion timestamp validation in milliseconds.
332
- * Allows for minor time differences between IdP and SP servers.
333
- *
334
- * Defaults to 300000 (5 minutes) to accommodate:
335
- * - Network latency and processing time
336
- * - Clock synchronization differences (NTP drift)
337
- * - Distributed systems across timezones
338
- *
339
- * For stricter security, reduce to 1-2 minutes (60000-120000).
340
- * For highly distributed systems, increase up to 10 minutes (600000).
341
- *
342
- * @default 300000 (5 minutes)
343
- */
344
- clockSkew?: number;
345
- /**
346
- * Require timestamp conditions (NotBefore/NotOnOrAfter) in SAML assertions.
347
- * When enabled, assertions without timestamp conditions will be rejected.
348
- *
349
- * When disabled (default), assertions without timestamps are accepted
350
- * but a warning is logged.
351
- *
352
- * **SAML Spec Notes:**
353
- * - SAML 2.0 Core: Timestamps are OPTIONAL
354
- * - SAML2Int (enterprise profile): Timestamps are REQUIRED
355
- *
356
- * **Recommendation:** Enable for enterprise/production deployments
357
- * where your IdP follows SAML2Int (Okta, Azure AD, OneLogin, etc.)
358
- *
359
- * @default false
360
- */
361
- requireTimestamps?: boolean;
362
- /**
363
- * Algorithm validation options for SAML responses.
364
- *
365
- * Controls behavior when deprecated algorithms (SHA-1, RSA1_5, 3DES)
366
- * are detected in SAML responses.
367
- *
368
- * @example
369
- * ```ts
370
- * algorithms: {
371
- * onDeprecated: "reject" // Reject deprecated algorithms
372
- * }
373
- * ```
374
- */
375
- algorithms?: AlgorithmValidationOptions;
376
- /**
377
- * Maximum allowed size for SAML responses in bytes.
378
- *
379
- * @default 262144 (256KB)
380
- */
381
- maxResponseSize?: number;
382
- /**
383
- * Maximum allowed size for IdP metadata XML in bytes.
384
- *
385
- * @default 102400 (100KB)
386
- */
387
- maxMetadataSize?: number;
388
- /**
389
- * Enable SAML Single Logout
390
- * @default false
391
- */
392
- enableSingleLogout?: boolean;
393
- /**
394
- * TTL for LogoutRequest records in milliseconds
395
- * @default 300000 (5 minutes)
396
- */
397
- logoutRequestTTL?: number;
398
- /**
399
- * Require signed LogoutRequests from IdP
400
- * @default false
401
- */
402
- wantLogoutRequestSigned?: boolean;
403
- /**
404
- * Require signed LogoutResponses from IdP
405
- * @default false
406
- */
407
- wantLogoutResponseSigned?: boolean;
408
- };
409
- }
410
-
411
- export interface Member {
412
- id: string;
413
- userId: string;
414
- organizationId: string;
415
- role: string;
416
- }
package/src/utils.test.ts DELETED
@@ -1,106 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { validateEmailDomain } from "./utils";
3
-
4
- /**
5
- * @see https://github.com/better-auth/better-auth/issues/7324
6
- */
7
- describe("validateEmailDomain", () => {
8
- // Tests for issue #7324: Enterprise multi-domain SSO support
9
- // https://github.com/better-auth/better-auth/issues/7324
10
-
11
- describe("single domain", () => {
12
- it("should validate email matches domain exactly", () => {
13
- expect(validateEmailDomain("user@company.com", "company.com")).toBe(true);
14
- });
15
-
16
- it("should validate email matches subdomain", () => {
17
- expect(validateEmailDomain("user@hr.company.com", "company.com")).toBe(
18
- true,
19
- );
20
- expect(
21
- validateEmailDomain("user@dept.hr.company.com", "company.com"),
22
- ).toBe(true);
23
- });
24
-
25
- it("should reject email from different domain", () => {
26
- expect(validateEmailDomain("user@other.com", "company.com")).toBe(false);
27
- });
28
-
29
- it("should reject email where domain is a suffix but not subdomain", () => {
30
- // "notcompany.com" should not match "company.com"
31
- expect(validateEmailDomain("user@notcompany.com", "company.com")).toBe(
32
- false,
33
- );
34
- });
35
-
36
- it("should be case-insensitive", () => {
37
- expect(validateEmailDomain("USER@COMPANY.COM", "company.com")).toBe(true);
38
- expect(validateEmailDomain("user@company.com", "COMPANY.COM")).toBe(true);
39
- });
40
- });
41
-
42
- describe("multiple domains (enterprise multi-domain SSO)", () => {
43
- // Issue #7324: Single IdP (e.g., Okta) serving multiple email domains
44
- it("should validate email against any domain in comma-separated list", () => {
45
- const domains = "company.com,subsidiary.com,acquired-company.com";
46
- expect(validateEmailDomain("user@company.com", domains)).toBe(true);
47
- expect(validateEmailDomain("user@subsidiary.com", domains)).toBe(true);
48
- expect(validateEmailDomain("user@acquired-company.com", domains)).toBe(
49
- true,
50
- );
51
- });
52
-
53
- it("should validate subdomains for any domain in the list", () => {
54
- const domains = "company.com,subsidiary.com";
55
- expect(validateEmailDomain("user@hr.company.com", domains)).toBe(true);
56
- expect(validateEmailDomain("user@dept.subsidiary.com", domains)).toBe(
57
- true,
58
- );
59
- });
60
-
61
- it("should reject email not matching any domain", () => {
62
- const domains = "company.com,subsidiary.com,acquired-company.com";
63
- expect(validateEmailDomain("user@other.com", domains)).toBe(false);
64
- expect(validateEmailDomain("user@notcompany.com", domains)).toBe(false);
65
- });
66
-
67
- it("should handle whitespace in domain list", () => {
68
- const domains = "company.com, subsidiary.com , acquired-company.com";
69
- expect(validateEmailDomain("user@company.com", domains)).toBe(true);
70
- expect(validateEmailDomain("user@subsidiary.com", domains)).toBe(true);
71
- expect(validateEmailDomain("user@acquired-company.com", domains)).toBe(
72
- true,
73
- );
74
- });
75
-
76
- it("should handle empty domains in list gracefully", () => {
77
- const domains = "company.com,,subsidiary.com";
78
- expect(validateEmailDomain("user@company.com", domains)).toBe(true);
79
- expect(validateEmailDomain("user@subsidiary.com", domains)).toBe(true);
80
- });
81
-
82
- it("should be case-insensitive for multiple domains", () => {
83
- const domains = "Company.COM,SUBSIDIARY.com";
84
- expect(validateEmailDomain("user@company.com", domains)).toBe(true);
85
- expect(validateEmailDomain("USER@SUBSIDIARY.COM", domains)).toBe(true);
86
- });
87
- });
88
-
89
- describe("edge cases", () => {
90
- it("should return false for empty email", () => {
91
- expect(validateEmailDomain("", "company.com")).toBe(false);
92
- });
93
-
94
- it("should return false for empty domain", () => {
95
- expect(validateEmailDomain("user@company.com", "")).toBe(false);
96
- });
97
-
98
- it("should return false for email without @", () => {
99
- expect(validateEmailDomain("usercompany.com", "company.com")).toBe(false);
100
- });
101
-
102
- it("should return false for domain list with only whitespace/commas", () => {
103
- expect(validateEmailDomain("user@company.com", ", ,")).toBe(false);
104
- });
105
- });
106
- });
package/src/utils.ts DELETED
@@ -1,81 +0,0 @@
1
- import { X509Certificate } from "node:crypto";
2
-
3
- /**
4
- * Safely parses a value that might be a JSON string or already a parsed object.
5
- * This handles cases where ORMs like Drizzle might return already parsed objects
6
- * instead of JSON strings from TEXT/JSON columns.
7
- *
8
- * @param value - The value to parse (string, object, null, or undefined)
9
- * @returns The parsed object or null
10
- * @throws Error if string parsing fails
11
- */
12
- export function safeJsonParse<T>(
13
- value: string | T | null | undefined,
14
- ): T | null {
15
- if (!value) return null;
16
-
17
- if (typeof value === "object") {
18
- return value as T;
19
- }
20
-
21
- if (typeof value === "string") {
22
- try {
23
- return JSON.parse(value) as T;
24
- } catch (error) {
25
- throw new Error(
26
- `Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`,
27
- );
28
- }
29
- }
30
-
31
- return null;
32
- }
33
-
34
- /**
35
- * Checks if a domain matches any domain in a comma-separated list.
36
- */
37
- export const domainMatches = (searchDomain: string, domainList: string) => {
38
- const search = searchDomain.toLowerCase();
39
- const domains = domainList
40
- .split(",")
41
- .map((d) => d.trim().toLowerCase())
42
- .filter(Boolean);
43
- return domains.some((d) => search === d || search.endsWith(`.${d}`));
44
- };
45
-
46
- /**
47
- * Validates email domain against allowed domain(s).
48
- * Supports comma-separated domains for multi-domain SSO.
49
- */
50
- export const validateEmailDomain = (email: string, domain: string) => {
51
- const emailDomain = email.split("@")[1]?.toLowerCase();
52
- if (!emailDomain || !domain) {
53
- return false;
54
- }
55
- return domainMatches(emailDomain, domain);
56
- };
57
-
58
- export function parseCertificate(certPem: string) {
59
- // SAML metadata X509Certificate elements contain raw base64 without PEM headers,
60
- // but users may also provide full PEM-formatted certificates. Normalize to PEM.
61
- const normalized = certPem.includes("-----BEGIN")
62
- ? certPem
63
- : `-----BEGIN CERTIFICATE-----\n${certPem}\n-----END CERTIFICATE-----`;
64
-
65
- const cert = new X509Certificate(normalized);
66
-
67
- return {
68
- fingerprintSha256: cert.fingerprint256,
69
- notBefore: cert.validFrom,
70
- notAfter: cert.validTo,
71
- publicKeyAlgorithm:
72
- cert.publicKey.asymmetricKeyType?.toUpperCase() || "UNKNOWN",
73
- };
74
- }
75
-
76
- export function maskClientId(clientId: string): string {
77
- if (clientId.length <= 4) {
78
- return "****";
79
- }
80
- return `****${clientId.slice(-4)}`;
81
- }
package/tsconfig.json DELETED
@@ -1,14 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "lib": ["esnext", "dom", "dom.iterable"]
5
- },
6
- "references": [
7
- {
8
- "path": "../better-auth/tsconfig.json"
9
- },
10
- {
11
- "path": "../core/tsconfig.json"
12
- }
13
- ]
14
- }
package/tsdown.config.ts DELETED
@@ -1,8 +0,0 @@
1
- import { defineConfig } from "tsdown";
2
-
3
- export default defineConfig({
4
- dts: { build: true, incremental: true },
5
- format: ["esm"],
6
- entry: ["./src/index.ts", "./src/client.ts"],
7
- sourcemap: true,
8
- });
package/vitest.config.ts DELETED
@@ -1,8 +0,0 @@
1
- import { defineProject } from "vitest/config";
2
-
3
- export default defineProject({
4
- test: {
5
- clearMocks: true,
6
- restoreMocks: true,
7
- },
8
- });