@better-auth/sso 1.4.7-beta.4 → 1.4.8-beta.1
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 +7 -7
- package/dist/client.d.mts +1 -1
- package/dist/{index-GoyGoP_a.d.mts → index-DNWhGQW-.d.mts} +94 -77
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +537 -286
- package/package.json +3 -3
- package/src/constants.ts +42 -0
- package/src/domain-verification.test.ts +1 -0
- package/src/index.ts +38 -11
- package/src/linking/index.ts +2 -0
- package/src/linking/org-assignment.ts +158 -0
- package/src/linking/types.ts +10 -0
- package/src/oidc/discovery.test.ts +359 -25
- package/src/oidc/discovery.ts +168 -29
- package/src/oidc/errors.ts +6 -0
- package/src/oidc/types.ts +9 -0
- package/src/oidc.test.ts +3 -0
- package/src/routes/sso.ts +339 -332
- package/src/saml/algorithms.test.ts +205 -0
- package/src/saml/algorithms.ts +259 -0
- package/src/saml/index.ts +9 -0
- package/src/saml.test.ts +351 -127
- package/src/types.ts +18 -16
- package/src/authn-request-store.ts +0 -76
- package/src/authn-request.test.ts +0 -99
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.
|
|
4
|
+
"version": "1.4.8-beta.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.mjs",
|
|
7
7
|
"types": "dist/index.d.mts",
|
|
@@ -66,10 +66,10 @@
|
|
|
66
66
|
"express": "^5.1.0",
|
|
67
67
|
"oauth2-mock-server": "^8.2.0",
|
|
68
68
|
"tsdown": "^0.17.2",
|
|
69
|
-
"better-auth": "1.4.
|
|
69
|
+
"better-auth": "1.4.8-beta.1"
|
|
70
70
|
},
|
|
71
71
|
"peerDependencies": {
|
|
72
|
-
"better-auth": "1.4.
|
|
72
|
+
"better-auth": "1.4.8-beta.1"
|
|
73
73
|
},
|
|
74
74
|
"scripts": {
|
|
75
75
|
"test": "vitest",
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SAML Constants
|
|
3
|
+
*
|
|
4
|
+
* Centralized constants for SAML SSO functionality.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// Key Prefixes (for verification table storage)
|
|
9
|
+
// ============================================================================
|
|
10
|
+
|
|
11
|
+
/** Prefix for AuthnRequest IDs used in InResponseTo validation */
|
|
12
|
+
export const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
|
|
13
|
+
|
|
14
|
+
/** Prefix for used Assertion IDs used in replay protection */
|
|
15
|
+
export const USED_ASSERTION_KEY_PREFIX = "saml-used-assertion:";
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Time-To-Live (TTL) Defaults
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Default TTL for AuthnRequest records (5 minutes).
|
|
23
|
+
* This should be sufficient for most IdPs while protecting against stale requests.
|
|
24
|
+
*/
|
|
25
|
+
export const DEFAULT_AUTHN_REQUEST_TTL_MS = 5 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Default TTL for used assertion records (15 minutes).
|
|
29
|
+
* This should match the maximum expected NotOnOrAfter window plus clock skew.
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_ASSERTION_TTL_MS = 15 * 60 * 1000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Default clock skew tolerance (5 minutes).
|
|
35
|
+
* Allows for minor time differences between IdP and SP servers.
|
|
36
|
+
*
|
|
37
|
+
* Accommodates:
|
|
38
|
+
* - Network latency and processing time
|
|
39
|
+
* - Clock synchronization differences (NTP drift)
|
|
40
|
+
* - Distributed systems across timezones
|
|
41
|
+
*/
|
|
42
|
+
export const DEFAULT_CLOCK_SKEW_MS = 5 * 60 * 1000;
|
package/src/index.ts
CHANGED
|
@@ -1,14 +1,8 @@
|
|
|
1
1
|
import type { BetterAuthPlugin } from "better-auth";
|
|
2
|
+
import { createAuthMiddleware } from "better-auth/api";
|
|
2
3
|
import { XMLValidator } from "fast-xml-parser";
|
|
3
4
|
import * as saml from "samlify";
|
|
4
|
-
import
|
|
5
|
-
AuthnRequestRecord,
|
|
6
|
-
AuthnRequestStore,
|
|
7
|
-
} from "./authn-request-store";
|
|
8
|
-
import {
|
|
9
|
-
createInMemoryAuthnRequestStore,
|
|
10
|
-
DEFAULT_AUTHN_REQUEST_TTL_MS,
|
|
11
|
-
} from "./authn-request-store";
|
|
5
|
+
import { assignOrganizationByDomain } from "./linking";
|
|
12
6
|
import {
|
|
13
7
|
requestDomainVerification,
|
|
14
8
|
verifyDomain,
|
|
@@ -23,17 +17,23 @@ import {
|
|
|
23
17
|
} from "./routes/sso";
|
|
24
18
|
|
|
25
19
|
export {
|
|
26
|
-
DEFAULT_CLOCK_SKEW_MS,
|
|
27
20
|
type SAMLConditions,
|
|
28
21
|
type TimestampValidationOptions,
|
|
29
22
|
validateSAMLTimestamp,
|
|
30
23
|
} from "./routes/sso";
|
|
31
24
|
|
|
25
|
+
export {
|
|
26
|
+
type AlgorithmValidationOptions,
|
|
27
|
+
DataEncryptionAlgorithm,
|
|
28
|
+
type DeprecatedAlgorithmBehavior,
|
|
29
|
+
DigestAlgorithm,
|
|
30
|
+
KeyEncryptionAlgorithm,
|
|
31
|
+
SignatureAlgorithm,
|
|
32
|
+
} from "./saml";
|
|
33
|
+
|
|
32
34
|
import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "./types";
|
|
33
35
|
|
|
34
36
|
export type { SAMLConfig, OIDCConfig, SSOOptions, SSOProvider };
|
|
35
|
-
export type { AuthnRequestStore, AuthnRequestRecord };
|
|
36
|
-
export { createInMemoryAuthnRequestStore, DEFAULT_AUTHN_REQUEST_TTL_MS };
|
|
37
37
|
|
|
38
38
|
export {
|
|
39
39
|
computeDiscoveryUrl,
|
|
@@ -134,6 +134,33 @@ export function sso<O extends SSOOptions>(options?: O | undefined): any {
|
|
|
134
134
|
return {
|
|
135
135
|
id: "sso",
|
|
136
136
|
endpoints,
|
|
137
|
+
hooks: {
|
|
138
|
+
after: [
|
|
139
|
+
{
|
|
140
|
+
matcher(context) {
|
|
141
|
+
return context.path?.startsWith("/callback/") ?? false;
|
|
142
|
+
},
|
|
143
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
144
|
+
const newSession = ctx.context.newSession;
|
|
145
|
+
if (!newSession?.user) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const isOrgPluginEnabled = ctx.context.options.plugins?.find(
|
|
150
|
+
(plugin: { id: string }) => plugin.id === "organization",
|
|
151
|
+
);
|
|
152
|
+
if (!isOrgPluginEnabled) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
await assignOrganizationByDomain(ctx, {
|
|
157
|
+
user: newSession.user,
|
|
158
|
+
provisioningOptions: options?.organizationProvisioning,
|
|
159
|
+
});
|
|
160
|
+
}),
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
},
|
|
137
164
|
schema: {
|
|
138
165
|
ssoProvider: {
|
|
139
166
|
modelName: options?.modelName ?? "ssoProvider",
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { GenericEndpointContext, OAuth2Tokens, User } from "better-auth";
|
|
2
|
+
import type { SSOOptions, SSOProvider } from "../types";
|
|
3
|
+
import type { NormalizedSSOProfile } from "./types";
|
|
4
|
+
|
|
5
|
+
export interface OrganizationProvisioningOptions {
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
defaultRole?: "member" | "admin";
|
|
8
|
+
getRole?: (data: {
|
|
9
|
+
user: User & Record<string, any>;
|
|
10
|
+
userInfo: Record<string, any>;
|
|
11
|
+
token?: OAuth2Tokens;
|
|
12
|
+
provider: SSOProvider<SSOOptions>;
|
|
13
|
+
}) => Promise<"member" | "admin">;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AssignOrganizationFromProviderOptions {
|
|
17
|
+
user: User;
|
|
18
|
+
profile: NormalizedSSOProfile;
|
|
19
|
+
provider: SSOProvider<SSOOptions>;
|
|
20
|
+
token?: OAuth2Tokens;
|
|
21
|
+
provisioningOptions?: OrganizationProvisioningOptions;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Assigns a user to an organization based on the SSO provider's organizationId.
|
|
26
|
+
* Used in SSO flows (OIDC, SAML) where the provider is already linked to an org.
|
|
27
|
+
*/
|
|
28
|
+
export async function assignOrganizationFromProvider(
|
|
29
|
+
ctx: GenericEndpointContext,
|
|
30
|
+
options: AssignOrganizationFromProviderOptions,
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
const { user, profile, provider, token, provisioningOptions } = options;
|
|
33
|
+
|
|
34
|
+
if (!provider.organizationId) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (provisioningOptions?.disabled) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const isOrgPluginEnabled = ctx.context.options.plugins?.find(
|
|
43
|
+
(plugin) => plugin.id === "organization",
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
if (!isOrgPluginEnabled) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const isAlreadyMember = await ctx.context.adapter.findOne({
|
|
51
|
+
model: "member",
|
|
52
|
+
where: [
|
|
53
|
+
{ field: "organizationId", value: provider.organizationId },
|
|
54
|
+
{ field: "userId", value: user.id },
|
|
55
|
+
],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (isAlreadyMember) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const role = provisioningOptions?.getRole
|
|
63
|
+
? await provisioningOptions.getRole({
|
|
64
|
+
user,
|
|
65
|
+
userInfo: profile.rawAttributes || {},
|
|
66
|
+
token,
|
|
67
|
+
provider,
|
|
68
|
+
})
|
|
69
|
+
: provisioningOptions?.defaultRole || "member";
|
|
70
|
+
|
|
71
|
+
await ctx.context.adapter.create({
|
|
72
|
+
model: "member",
|
|
73
|
+
data: {
|
|
74
|
+
organizationId: provider.organizationId,
|
|
75
|
+
userId: user.id,
|
|
76
|
+
role,
|
|
77
|
+
createdAt: new Date(),
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface AssignOrganizationByDomainOptions {
|
|
83
|
+
user: User;
|
|
84
|
+
provisioningOptions?: OrganizationProvisioningOptions;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Assigns a user to an organization based on their email domain.
|
|
89
|
+
* Looks up SSO providers that match the user's email domain and assigns
|
|
90
|
+
* the user to the associated organization.
|
|
91
|
+
*
|
|
92
|
+
* This enables domain-based org assignment for non-SSO sign-in methods
|
|
93
|
+
* (e.g., Google OAuth with @acme.com email gets added to Acme's org).
|
|
94
|
+
*/
|
|
95
|
+
export async function assignOrganizationByDomain(
|
|
96
|
+
ctx: GenericEndpointContext,
|
|
97
|
+
options: AssignOrganizationByDomainOptions,
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
const { user, provisioningOptions } = options;
|
|
100
|
+
|
|
101
|
+
if (provisioningOptions?.disabled) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const isOrgPluginEnabled = ctx.context.options.plugins?.find(
|
|
106
|
+
(plugin) => plugin.id === "organization",
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
if (!isOrgPluginEnabled) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const domain = user.email.split("@")[1];
|
|
114
|
+
if (!domain) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const ssoProvider = await ctx.context.adapter.findOne<
|
|
119
|
+
SSOProvider<SSOOptions>
|
|
120
|
+
>({
|
|
121
|
+
model: "ssoProvider",
|
|
122
|
+
where: [{ field: "domain", value: domain }],
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (!ssoProvider || !ssoProvider.organizationId) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const isAlreadyMember = await ctx.context.adapter.findOne({
|
|
130
|
+
model: "member",
|
|
131
|
+
where: [
|
|
132
|
+
{ field: "organizationId", value: ssoProvider.organizationId },
|
|
133
|
+
{ field: "userId", value: user.id },
|
|
134
|
+
],
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (isAlreadyMember) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const role = provisioningOptions?.getRole
|
|
142
|
+
? await provisioningOptions.getRole({
|
|
143
|
+
user,
|
|
144
|
+
userInfo: {},
|
|
145
|
+
provider: ssoProvider,
|
|
146
|
+
})
|
|
147
|
+
: provisioningOptions?.defaultRole || "member";
|
|
148
|
+
|
|
149
|
+
await ctx.context.adapter.create({
|
|
150
|
+
model: "member",
|
|
151
|
+
data: {
|
|
152
|
+
organizationId: ssoProvider.organizationId,
|
|
153
|
+
userId: user.id,
|
|
154
|
+
role,
|
|
155
|
+
createdAt: new Date(),
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|