@better-auth/sso 1.4.7 → 1.4.8-beta.2

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.4.7",
4
+ "version": "1.4.8-beta.2",
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.7"
69
+ "better-auth": "1.4.8-beta.2"
70
70
  },
71
71
  "peerDependencies": {
72
- "better-auth": "1.4.7"
72
+ "better-auth": "1.4.8-beta.2"
73
73
  },
74
74
  "scripts": {
75
75
  "test": "vitest",
@@ -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;
@@ -92,6 +92,7 @@ describe("Domain verification", async () => {
92
92
  body: {
93
93
  userId: response.data.user.id,
94
94
  role: "member",
95
+ organizationId,
95
96
  },
96
97
  headers,
97
98
  });
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 type {
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",
@@ -183,5 +210,6 @@ export function sso<O extends SSOOptions>(options?: O | undefined): any {
183
210
  },
184
211
  },
185
212
  },
213
+ options: options as NoInfer<O>,
186
214
  } satisfies BetterAuthPlugin;
187
215
  }
@@ -0,0 +1,2 @@
1
+ export * from "./org-assignment";
2
+ export * from "./types";
@@ -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
+ }
@@ -0,0 +1,10 @@
1
+ export interface NormalizedSSOProfile {
2
+ providerType: "saml" | "oidc";
3
+ providerId: string;
4
+ accountId: string;
5
+ email: string;
6
+ emailVerified: boolean;
7
+ name?: string;
8
+ image?: string;
9
+ rawAttributes?: Record<string, unknown>;
10
+ }