@hammadj/better-auth-sso 1.5.0-beta.9
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 +116 -0
- package/LICENSE.md +20 -0
- package/dist/client.d.mts +10 -0
- package/dist/client.mjs +15 -0
- package/dist/client.mjs.map +1 -0
- package/dist/index.d.mts +738 -0
- package/dist/index.mjs +2953 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +87 -0
- package/src/client.ts +29 -0
- package/src/constants.ts +58 -0
- package/src/domain-verification.test.ts +551 -0
- package/src/index.ts +265 -0
- package/src/linking/index.ts +2 -0
- package/src/linking/org-assignment.test.ts +325 -0
- package/src/linking/org-assignment.ts +176 -0
- package/src/linking/types.ts +10 -0
- package/src/oidc/discovery.test.ts +1157 -0
- package/src/oidc/discovery.ts +494 -0
- package/src/oidc/errors.ts +92 -0
- package/src/oidc/index.ts +31 -0
- package/src/oidc/types.ts +219 -0
- package/src/oidc.test.ts +688 -0
- package/src/providers.test.ts +1326 -0
- package/src/routes/domain-verification.ts +275 -0
- package/src/routes/providers.ts +565 -0
- package/src/routes/schemas.ts +96 -0
- package/src/routes/sso.ts +2750 -0
- package/src/saml/algorithms.test.ts +449 -0
- package/src/saml/algorithms.ts +338 -0
- package/src/saml/assertions.test.ts +239 -0
- package/src/saml/assertions.ts +62 -0
- package/src/saml/index.ts +13 -0
- package/src/saml/parser.ts +56 -0
- package/src/saml-state.ts +78 -0
- package/src/saml.test.ts +4319 -0
- package/src/types.ts +365 -0
- package/src/utils.test.ts +103 -0
- package/src/utils.ts +81 -0
- package/tsconfig.json +14 -0
- package/tsdown.config.ts +9 -0
- package/vitest.config.ts +3 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
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
|
+
}
|
|
64
|
+
| undefined;
|
|
65
|
+
spMetadata: {
|
|
66
|
+
metadata?: string | undefined;
|
|
67
|
+
entityID?: string | undefined;
|
|
68
|
+
binding?: string | undefined;
|
|
69
|
+
privateKey?: string | undefined;
|
|
70
|
+
privateKeyPass?: string | undefined;
|
|
71
|
+
isAssertionEncrypted?: boolean | undefined;
|
|
72
|
+
encPrivateKey?: string | undefined;
|
|
73
|
+
encPrivateKeyPass?: string | undefined;
|
|
74
|
+
};
|
|
75
|
+
wantAssertionsSigned?: boolean | undefined;
|
|
76
|
+
authnRequestsSigned?: boolean | undefined;
|
|
77
|
+
signatureAlgorithm?: string | undefined;
|
|
78
|
+
digestAlgorithm?: string | undefined;
|
|
79
|
+
identifierFormat?: string | undefined;
|
|
80
|
+
privateKey?: string | undefined;
|
|
81
|
+
decryptionPvk?: string | undefined;
|
|
82
|
+
additionalParams?: Record<string, any> | undefined;
|
|
83
|
+
mapping?: SAMLMapping | undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type BaseSSOProvider = {
|
|
87
|
+
issuer: string;
|
|
88
|
+
oidcConfig?: OIDCConfig | undefined;
|
|
89
|
+
samlConfig?: SAMLConfig | undefined;
|
|
90
|
+
userId: string;
|
|
91
|
+
providerId: string;
|
|
92
|
+
organizationId?: string | undefined;
|
|
93
|
+
domain: string;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export type SSOProvider<O extends SSOOptions> =
|
|
97
|
+
O["domainVerification"] extends { enabled: true }
|
|
98
|
+
? {
|
|
99
|
+
domainVerified: boolean;
|
|
100
|
+
} & BaseSSOProvider
|
|
101
|
+
: BaseSSOProvider;
|
|
102
|
+
|
|
103
|
+
export interface SSOOptions {
|
|
104
|
+
/**
|
|
105
|
+
* custom function to provision a user when they sign in with an SSO provider.
|
|
106
|
+
*/
|
|
107
|
+
provisionUser?:
|
|
108
|
+
| ((data: {
|
|
109
|
+
/**
|
|
110
|
+
* The user object from the database
|
|
111
|
+
*/
|
|
112
|
+
user: User & Record<string, any>;
|
|
113
|
+
/**
|
|
114
|
+
* The user info object from the provider
|
|
115
|
+
*/
|
|
116
|
+
userInfo: Record<string, any>;
|
|
117
|
+
/**
|
|
118
|
+
* The OAuth2 tokens from the provider
|
|
119
|
+
*/
|
|
120
|
+
token?: OAuth2Tokens;
|
|
121
|
+
/**
|
|
122
|
+
* The SSO provider
|
|
123
|
+
*/
|
|
124
|
+
provider: SSOProvider<SSOOptions>;
|
|
125
|
+
}) => Awaitable<void>)
|
|
126
|
+
| undefined;
|
|
127
|
+
/**
|
|
128
|
+
* Organization provisioning options
|
|
129
|
+
*/
|
|
130
|
+
organizationProvisioning?:
|
|
131
|
+
| {
|
|
132
|
+
disabled?: boolean;
|
|
133
|
+
defaultRole?: "member" | "admin";
|
|
134
|
+
getRole?: (data: {
|
|
135
|
+
/**
|
|
136
|
+
* The user object from the database
|
|
137
|
+
*/
|
|
138
|
+
user: User & Record<string, any>;
|
|
139
|
+
/**
|
|
140
|
+
* The user info object from the provider
|
|
141
|
+
*/
|
|
142
|
+
userInfo: Record<string, any>;
|
|
143
|
+
/**
|
|
144
|
+
* The OAuth2 tokens from the provider
|
|
145
|
+
*/
|
|
146
|
+
token?: OAuth2Tokens;
|
|
147
|
+
/**
|
|
148
|
+
* The SSO provider
|
|
149
|
+
*/
|
|
150
|
+
provider: SSOProvider<SSOOptions>;
|
|
151
|
+
}) => Promise<"member" | "admin">;
|
|
152
|
+
}
|
|
153
|
+
| undefined;
|
|
154
|
+
/**
|
|
155
|
+
* Default SSO provider configurations for testing.
|
|
156
|
+
* These will take the precedence over the database providers.
|
|
157
|
+
*/
|
|
158
|
+
defaultSSO?:
|
|
159
|
+
| Array<{
|
|
160
|
+
/**
|
|
161
|
+
* The domain to match for this default provider.
|
|
162
|
+
* This is only used to match incoming requests to this default provider.
|
|
163
|
+
*/
|
|
164
|
+
domain: string;
|
|
165
|
+
/**
|
|
166
|
+
* The provider ID to use
|
|
167
|
+
*/
|
|
168
|
+
providerId: string;
|
|
169
|
+
/**
|
|
170
|
+
* SAML configuration
|
|
171
|
+
*/
|
|
172
|
+
samlConfig?: SAMLConfig;
|
|
173
|
+
/**
|
|
174
|
+
* OIDC configuration
|
|
175
|
+
*/
|
|
176
|
+
oidcConfig?: OIDCConfig;
|
|
177
|
+
}>
|
|
178
|
+
| undefined;
|
|
179
|
+
/**
|
|
180
|
+
* Override user info with the provider info.
|
|
181
|
+
* @default false
|
|
182
|
+
*/
|
|
183
|
+
defaultOverrideUserInfo?: boolean | undefined;
|
|
184
|
+
/**
|
|
185
|
+
* Disable implicit sign up for new users. When set to true for the provider,
|
|
186
|
+
* sign-in need to be called with with requestSignUp as true to create new users.
|
|
187
|
+
*/
|
|
188
|
+
disableImplicitSignUp?: boolean | undefined;
|
|
189
|
+
/**
|
|
190
|
+
* The model name for the SSO provider table. Defaults to "ssoProvider".
|
|
191
|
+
*/
|
|
192
|
+
modelName?: string;
|
|
193
|
+
/**
|
|
194
|
+
* Map fields
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
* ```ts
|
|
198
|
+
* {
|
|
199
|
+
* samlConfig: "saml_config"
|
|
200
|
+
* }
|
|
201
|
+
* ```
|
|
202
|
+
*/
|
|
203
|
+
fields?: {
|
|
204
|
+
issuer?: string | undefined;
|
|
205
|
+
oidcConfig?: string | undefined;
|
|
206
|
+
samlConfig?: string | undefined;
|
|
207
|
+
userId?: string | undefined;
|
|
208
|
+
providerId?: string | undefined;
|
|
209
|
+
organizationId?: string | undefined;
|
|
210
|
+
domain?: string | undefined;
|
|
211
|
+
};
|
|
212
|
+
/**
|
|
213
|
+
* Configure the maximum number of SSO providers a user can register.
|
|
214
|
+
* You can also pass a function that returns a number.
|
|
215
|
+
* Set to 0 to disable SSO provider registration.
|
|
216
|
+
*
|
|
217
|
+
* @example
|
|
218
|
+
* ```ts
|
|
219
|
+
* providersLimit: async (user) => {
|
|
220
|
+
* const plan = await getUserPlan(user);
|
|
221
|
+
* return plan.name === "pro" ? 10 : 1;
|
|
222
|
+
* }
|
|
223
|
+
* ```
|
|
224
|
+
* @default 10
|
|
225
|
+
*/
|
|
226
|
+
providersLimit?: (number | ((user: User) => Awaitable<number>)) | undefined;
|
|
227
|
+
/**
|
|
228
|
+
* Trust the email verified flag from the provider.
|
|
229
|
+
*
|
|
230
|
+
* ⚠️ Use this with caution — it can lead to account takeover if misused. Only enable it if users **cannot freely register new providers**. You can
|
|
231
|
+
* prevent that by using `disabledPaths` or other safeguards to block provider registration from the client.
|
|
232
|
+
*
|
|
233
|
+
* If you want to allow account linking for specific trusted providers, enable the `accountLinking` option in your auth config and specify those
|
|
234
|
+
* providers in the `trustedProviders` list.
|
|
235
|
+
*
|
|
236
|
+
* @default false
|
|
237
|
+
*
|
|
238
|
+
* @deprecated This option is discouraged for new projects. Relying on provider-level `email_verified` is a weaker
|
|
239
|
+
* trust signal compared to using `trustedProviders` in `accountLinking` or enabling `domainVerification` for SSO.
|
|
240
|
+
* Existing configurations will continue to work, but new integrations should use explicit trust mechanisms.
|
|
241
|
+
* This option may be removed in a future major version.
|
|
242
|
+
*/
|
|
243
|
+
trustEmailVerified?: boolean | undefined;
|
|
244
|
+
/**
|
|
245
|
+
* Enable domain verification on SSO providers
|
|
246
|
+
*
|
|
247
|
+
* When this option is enabled, new SSO providers will require the associated domain to be verified by the owner
|
|
248
|
+
* prior to allowing sign-ins.
|
|
249
|
+
*/
|
|
250
|
+
domainVerification?: {
|
|
251
|
+
/**
|
|
252
|
+
* Enables or disables the domain verification feature
|
|
253
|
+
*/
|
|
254
|
+
enabled?: boolean;
|
|
255
|
+
/**
|
|
256
|
+
* Prefix used to generate the domain verification token
|
|
257
|
+
*
|
|
258
|
+
* @default "better-auth-token-"
|
|
259
|
+
*/
|
|
260
|
+
tokenPrefix?: string;
|
|
261
|
+
};
|
|
262
|
+
/**
|
|
263
|
+
* SAML security options for AuthnRequest/InResponseTo validation.
|
|
264
|
+
* This prevents unsolicited responses, replay attacks, and cross-provider injection.
|
|
265
|
+
*/
|
|
266
|
+
saml?: {
|
|
267
|
+
/**
|
|
268
|
+
* Enable InResponseTo validation for SP-initiated SAML flows.
|
|
269
|
+
* When enabled, AuthnRequest IDs are tracked and validated against SAML responses.
|
|
270
|
+
*
|
|
271
|
+
* Storage behavior:
|
|
272
|
+
* - Uses `secondaryStorage` (e.g., Redis) if configured in your auth options
|
|
273
|
+
* - Falls back to the verification table in the database otherwise
|
|
274
|
+
*
|
|
275
|
+
* This works correctly in serverless environments without any additional configuration.
|
|
276
|
+
*
|
|
277
|
+
* @default false
|
|
278
|
+
*/
|
|
279
|
+
enableInResponseToValidation?: boolean;
|
|
280
|
+
/**
|
|
281
|
+
* Allow IdP-initiated SSO (unsolicited SAML responses).
|
|
282
|
+
* When true, responses without InResponseTo are accepted.
|
|
283
|
+
* When false, all responses must correlate to a stored AuthnRequest.
|
|
284
|
+
*
|
|
285
|
+
* Only applies when InResponseTo validation is enabled.
|
|
286
|
+
*
|
|
287
|
+
* @default true
|
|
288
|
+
*/
|
|
289
|
+
allowIdpInitiated?: boolean;
|
|
290
|
+
/**
|
|
291
|
+
* TTL for AuthnRequest records in milliseconds.
|
|
292
|
+
* Requests older than this will be rejected.
|
|
293
|
+
*
|
|
294
|
+
* Only applies when InResponseTo validation is enabled.
|
|
295
|
+
*
|
|
296
|
+
* @default 300000 (5 minutes)
|
|
297
|
+
*/
|
|
298
|
+
requestTTL?: number;
|
|
299
|
+
/**
|
|
300
|
+
* Clock skew tolerance for SAML assertion timestamp validation in milliseconds.
|
|
301
|
+
* Allows for minor time differences between IdP and SP servers.
|
|
302
|
+
*
|
|
303
|
+
* Defaults to 300000 (5 minutes) to accommodate:
|
|
304
|
+
* - Network latency and processing time
|
|
305
|
+
* - Clock synchronization differences (NTP drift)
|
|
306
|
+
* - Distributed systems across timezones
|
|
307
|
+
*
|
|
308
|
+
* For stricter security, reduce to 1-2 minutes (60000-120000).
|
|
309
|
+
* For highly distributed systems, increase up to 10 minutes (600000).
|
|
310
|
+
*
|
|
311
|
+
* @default 300000 (5 minutes)
|
|
312
|
+
*/
|
|
313
|
+
clockSkew?: number;
|
|
314
|
+
/**
|
|
315
|
+
* Require timestamp conditions (NotBefore/NotOnOrAfter) in SAML assertions.
|
|
316
|
+
* When enabled, assertions without timestamp conditions will be rejected.
|
|
317
|
+
*
|
|
318
|
+
* When disabled (default), assertions without timestamps are accepted
|
|
319
|
+
* but a warning is logged.
|
|
320
|
+
*
|
|
321
|
+
* **SAML Spec Notes:**
|
|
322
|
+
* - SAML 2.0 Core: Timestamps are OPTIONAL
|
|
323
|
+
* - SAML2Int (enterprise profile): Timestamps are REQUIRED
|
|
324
|
+
*
|
|
325
|
+
* **Recommendation:** Enable for enterprise/production deployments
|
|
326
|
+
* where your IdP follows SAML2Int (Okta, Azure AD, OneLogin, etc.)
|
|
327
|
+
*
|
|
328
|
+
* @default false
|
|
329
|
+
*/
|
|
330
|
+
requireTimestamps?: boolean;
|
|
331
|
+
/**
|
|
332
|
+
* Algorithm validation options for SAML responses.
|
|
333
|
+
*
|
|
334
|
+
* Controls behavior when deprecated algorithms (SHA-1, RSA1_5, 3DES)
|
|
335
|
+
* are detected in SAML responses.
|
|
336
|
+
*
|
|
337
|
+
* @example
|
|
338
|
+
* ```ts
|
|
339
|
+
* algorithms: {
|
|
340
|
+
* onDeprecated: "reject" // Reject deprecated algorithms
|
|
341
|
+
* }
|
|
342
|
+
* ```
|
|
343
|
+
*/
|
|
344
|
+
algorithms?: AlgorithmValidationOptions;
|
|
345
|
+
/**
|
|
346
|
+
* Maximum allowed size for SAML responses in bytes.
|
|
347
|
+
*
|
|
348
|
+
* @default 262144 (256KB)
|
|
349
|
+
*/
|
|
350
|
+
maxResponseSize?: number;
|
|
351
|
+
/**
|
|
352
|
+
* Maximum allowed size for IdP metadata XML in bytes.
|
|
353
|
+
*
|
|
354
|
+
* @default 102400 (100KB)
|
|
355
|
+
*/
|
|
356
|
+
maxMetadataSize?: number;
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export interface Member {
|
|
361
|
+
id: string;
|
|
362
|
+
userId: string;
|
|
363
|
+
organizationId: string;
|
|
364
|
+
role: string;
|
|
365
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { validateEmailDomain } from "./utils";
|
|
3
|
+
|
|
4
|
+
describe("validateEmailDomain", () => {
|
|
5
|
+
// Tests for issue #7324: Enterprise multi-domain SSO support
|
|
6
|
+
// https://github.com/better-auth/better-auth/issues/7324
|
|
7
|
+
|
|
8
|
+
describe("single domain", () => {
|
|
9
|
+
it("should validate email matches domain exactly", () => {
|
|
10
|
+
expect(validateEmailDomain("user@company.com", "company.com")).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("should validate email matches subdomain", () => {
|
|
14
|
+
expect(validateEmailDomain("user@hr.company.com", "company.com")).toBe(
|
|
15
|
+
true,
|
|
16
|
+
);
|
|
17
|
+
expect(
|
|
18
|
+
validateEmailDomain("user@dept.hr.company.com", "company.com"),
|
|
19
|
+
).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("should reject email from different domain", () => {
|
|
23
|
+
expect(validateEmailDomain("user@other.com", "company.com")).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should reject email where domain is a suffix but not subdomain", () => {
|
|
27
|
+
// "notcompany.com" should not match "company.com"
|
|
28
|
+
expect(validateEmailDomain("user@notcompany.com", "company.com")).toBe(
|
|
29
|
+
false,
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should be case-insensitive", () => {
|
|
34
|
+
expect(validateEmailDomain("USER@COMPANY.COM", "company.com")).toBe(true);
|
|
35
|
+
expect(validateEmailDomain("user@company.com", "COMPANY.COM")).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("multiple domains (enterprise multi-domain SSO)", () => {
|
|
40
|
+
// Issue #7324: Single IdP (e.g., Okta) serving multiple email domains
|
|
41
|
+
it("should validate email against any domain in comma-separated list", () => {
|
|
42
|
+
const domains = "company.com,subsidiary.com,acquired-company.com";
|
|
43
|
+
expect(validateEmailDomain("user@company.com", domains)).toBe(true);
|
|
44
|
+
expect(validateEmailDomain("user@subsidiary.com", domains)).toBe(true);
|
|
45
|
+
expect(validateEmailDomain("user@acquired-company.com", domains)).toBe(
|
|
46
|
+
true,
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("should validate subdomains for any domain in the list", () => {
|
|
51
|
+
const domains = "company.com,subsidiary.com";
|
|
52
|
+
expect(validateEmailDomain("user@hr.company.com", domains)).toBe(true);
|
|
53
|
+
expect(validateEmailDomain("user@dept.subsidiary.com", domains)).toBe(
|
|
54
|
+
true,
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("should reject email not matching any domain", () => {
|
|
59
|
+
const domains = "company.com,subsidiary.com,acquired-company.com";
|
|
60
|
+
expect(validateEmailDomain("user@other.com", domains)).toBe(false);
|
|
61
|
+
expect(validateEmailDomain("user@notcompany.com", domains)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("should handle whitespace in domain list", () => {
|
|
65
|
+
const domains = "company.com, subsidiary.com , acquired-company.com";
|
|
66
|
+
expect(validateEmailDomain("user@company.com", domains)).toBe(true);
|
|
67
|
+
expect(validateEmailDomain("user@subsidiary.com", domains)).toBe(true);
|
|
68
|
+
expect(validateEmailDomain("user@acquired-company.com", domains)).toBe(
|
|
69
|
+
true,
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("should handle empty domains in list gracefully", () => {
|
|
74
|
+
const domains = "company.com,,subsidiary.com";
|
|
75
|
+
expect(validateEmailDomain("user@company.com", domains)).toBe(true);
|
|
76
|
+
expect(validateEmailDomain("user@subsidiary.com", domains)).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("should be case-insensitive for multiple domains", () => {
|
|
80
|
+
const domains = "Company.COM,SUBSIDIARY.com";
|
|
81
|
+
expect(validateEmailDomain("user@company.com", domains)).toBe(true);
|
|
82
|
+
expect(validateEmailDomain("USER@SUBSIDIARY.COM", domains)).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("edge cases", () => {
|
|
87
|
+
it("should return false for empty email", () => {
|
|
88
|
+
expect(validateEmailDomain("", "company.com")).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("should return false for empty domain", () => {
|
|
92
|
+
expect(validateEmailDomain("user@company.com", "")).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("should return false for email without @", () => {
|
|
96
|
+
expect(validateEmailDomain("usercompany.com", "company.com")).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should return false for domain list with only whitespace/commas", () => {
|
|
100
|
+
expect(validateEmailDomain("user@company.com", ", ,")).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
});
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
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
ADDED
package/tsdown.config.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
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
|
+
external: ["better-auth", "better-call", "@better-fetch/fetch", "stripe"],
|
|
8
|
+
sourcemap: true,
|
|
9
|
+
});
|
package/vitest.config.ts
ADDED