@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/.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-CvpS40sl.d.mts → index-CBBJTszO.d.mts} +429 -19
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +1107 -489
- package/dist/index.mjs.map +1 -0
- package/package.json +17 -14
- package/src/client.ts +5 -1
- package/src/constants.ts +16 -0
- package/src/index.ts +55 -6
- package/src/linking/org-assignment.test.ts +1 -1
- package/src/linking/org-assignment.ts +20 -13
- package/src/oidc.test.ts +113 -1
- 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 +285 -65
- package/src/saml/algorithms.ts +1 -31
- package/src/saml/assertions.test.ts +239 -0
- package/src/saml/assertions.ts +62 -0
- package/src/saml/index.ts +2 -0
- package/src/saml/parser.ts +56 -0
- package/src/saml-state.ts +78 -0
- package/src/saml.test.ts +2133 -422
- package/src/types.ts +20 -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/src/types.ts
CHANGED
|
@@ -73,6 +73,7 @@ export interface SAMLConfig {
|
|
|
73
73
|
encPrivateKeyPass?: string | undefined;
|
|
74
74
|
};
|
|
75
75
|
wantAssertionsSigned?: boolean | undefined;
|
|
76
|
+
authnRequestsSigned?: boolean | undefined;
|
|
76
77
|
signatureAlgorithm?: string | undefined;
|
|
77
78
|
digestAlgorithm?: string | undefined;
|
|
78
79
|
identifierFormat?: string | undefined;
|
|
@@ -341,5 +342,24 @@ export interface SSOOptions {
|
|
|
341
342
|
* ```
|
|
342
343
|
*/
|
|
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;
|
|
344
357
|
};
|
|
345
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
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { X509Certificate } from "node:crypto";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Safely parses a value that might be a JSON string or already a parsed object.
|
|
3
5
|
* This handles cases where ORMs like Drizzle might return already parsed objects
|
|
@@ -29,13 +31,51 @@ export function safeJsonParse<T>(
|
|
|
29
31
|
return null;
|
|
30
32
|
}
|
|
31
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
|
+
*/
|
|
32
50
|
export const validateEmailDomain = (email: string, domain: string) => {
|
|
33
51
|
const emailDomain = email.split("@")[1]?.toLowerCase();
|
|
34
|
-
|
|
35
|
-
if (!emailDomain || !providerDomain) {
|
|
52
|
+
if (!emailDomain || !domain) {
|
|
36
53
|
return false;
|
|
37
54
|
}
|
|
38
|
-
return (
|
|
39
|
-
emailDomain === providerDomain || emailDomain.endsWith(`.${providerDomain}`)
|
|
40
|
-
);
|
|
55
|
+
return domainMatches(emailDomain, domain);
|
|
41
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
CHANGED