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

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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @better-auth/sso@1.4.8-beta.2 build /home/runner/work/better-auth/better-auth/packages/sso
2
+ > @better-auth/sso@1.4.8-beta.4 build /home/runner/work/better-auth/better-auth/packages/sso
3
3
  > tsdown
4
4
 
5
5
  ℹ tsdown v0.17.2 powered by rolldown v1.0.0-beta.53
@@ -7,10 +7,10 @@
7
7
  ℹ entry: src/index.ts, src/client.ts
8
8
  ℹ tsconfig: tsconfig.json
9
9
  ℹ Build start
10
- ℹ dist/index.mjs 92.45 kB │ gzip: 18.08 kB
10
+ ℹ dist/index.mjs 92.65 kB │ gzip: 18.13 kB
11
11
  ℹ dist/client.mjs  0.15 kB │ gzip: 0.14 kB
12
12
  ℹ dist/index.d.mts  1.48 kB │ gzip: 0.51 kB
13
13
  ℹ dist/client.d.mts  0.49 kB │ gzip: 0.30 kB
14
14
  ℹ dist/index-DNWhGQW-.d.mts 42.86 kB │ gzip: 8.79 kB
15
- ℹ 5 files, total: 137.43 kB
16
- ✔ Build complete in 11389ms
15
+ ℹ 5 files, total: 137.63 kB
16
+ ✔ Build complete in 11934ms
package/dist/index.mjs CHANGED
@@ -55,17 +55,22 @@ async function assignOrganizationFromProvider(ctx, options) {
55
55
  * (e.g., Google OAuth with @acme.com email gets added to Acme's org).
56
56
  */
57
57
  async function assignOrganizationByDomain(ctx, options) {
58
- const { user, provisioningOptions } = options;
58
+ const { user, provisioningOptions, domainVerification } = options;
59
59
  if (provisioningOptions?.disabled) return;
60
60
  if (!ctx.context.options.plugins?.find((plugin) => plugin.id === "organization")) return;
61
61
  const domain = user.email.split("@")[1];
62
62
  if (!domain) return;
63
+ const whereClause = [{
64
+ field: "domain",
65
+ value: domain
66
+ }];
67
+ if (domainVerification?.enabled) whereClause.push({
68
+ field: "domainVerified",
69
+ value: true
70
+ });
63
71
  const ssoProvider = await ctx.context.adapter.findOne({
64
72
  model: "ssoProvider",
65
- where: [{
66
- field: "domain",
67
- value: domain
68
- }]
73
+ where: whereClause
69
74
  });
70
75
  if (!ssoProvider || !ssoProvider.organizationId) return;
71
76
  if (await ctx.context.adapter.findOne({
@@ -2203,7 +2208,8 @@ function sso(options) {
2203
2208
  if (!ctx.context.options.plugins?.find((plugin) => plugin.id === "organization")) return;
2204
2209
  await assignOrganizationByDomain(ctx, {
2205
2210
  user: newSession.user,
2206
- provisioningOptions: options?.organizationProvisioning
2211
+ provisioningOptions: options?.organizationProvisioning,
2212
+ domainVerification: options?.domainVerification
2207
2213
  });
2208
2214
  })
2209
2215
  }] },
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.8-beta.2",
4
+ "version": "1.4.8-beta.4",
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.8-beta.2"
69
+ "better-auth": "1.4.8-beta.4"
70
70
  },
71
71
  "peerDependencies": {
72
- "better-auth": "1.4.8-beta.2"
72
+ "better-auth": "1.4.8-beta.4"
73
73
  },
74
74
  "scripts": {
75
75
  "test": "vitest",
package/src/index.ts CHANGED
@@ -156,6 +156,7 @@ export function sso<O extends SSOOptions>(options?: O | undefined): any {
156
156
  await assignOrganizationByDomain(ctx, {
157
157
  user: newSession.user,
158
158
  provisioningOptions: options?.organizationProvisioning,
159
+ domainVerification: options?.domainVerification,
159
160
  });
160
161
  }),
161
162
  },
@@ -0,0 +1,325 @@
1
+ import type { GenericEndpointContext, User } from "better-auth";
2
+ import { betterAuth } from "better-auth";
3
+ import { memoryAdapter } from "better-auth/adapters/memory";
4
+ import { organization } from "better-auth/plugins";
5
+ import { describe, expect, it } from "vitest";
6
+ import { sso } from "..";
7
+ import { assignOrganizationByDomain } from "./org-assignment";
8
+
9
+ describe("assignOrganizationByDomain", () => {
10
+ const createTestContext = () => {
11
+ const data = {
12
+ user: [] as User[],
13
+ session: [] as { id: string }[],
14
+ account: [] as { id: string }[],
15
+ ssoProvider: [] as {
16
+ id: string;
17
+ providerId: string;
18
+ issuer: string;
19
+ domain: string;
20
+ domainVerified: boolean;
21
+ organizationId: string | null;
22
+ userId: string;
23
+ }[],
24
+ member: [] as {
25
+ id: string;
26
+ organizationId: string;
27
+ userId: string;
28
+ role: string;
29
+ createdAt: Date;
30
+ }[],
31
+ organization: [] as {
32
+ id: string;
33
+ name: string;
34
+ slug: string;
35
+ createdAt: Date;
36
+ }[],
37
+ };
38
+
39
+ const memory = memoryAdapter(data);
40
+
41
+ const auth = betterAuth({
42
+ database: memory,
43
+ baseURL: "http://localhost:3000",
44
+ emailAndPassword: {
45
+ enabled: true,
46
+ },
47
+ plugins: [
48
+ sso({
49
+ domainVerification: {
50
+ enabled: true,
51
+ },
52
+ }),
53
+ organization(),
54
+ ],
55
+ });
56
+
57
+ const createContext = async () => {
58
+ const context = await auth.$context;
59
+ return { context } as Partial<GenericEndpointContext>;
60
+ };
61
+
62
+ return { auth, data, createContext };
63
+ };
64
+
65
+ const createUser = (overrides: Partial<User> = {}): User => ({
66
+ id: "user-1",
67
+ email: "alice@example.com",
68
+ name: "Alice",
69
+ emailVerified: true,
70
+ createdAt: new Date(),
71
+ updatedAt: new Date(),
72
+ ...overrides,
73
+ });
74
+
75
+ const createOrg = (
76
+ overrides: Partial<{ id: string; name: string; slug: string }> = {},
77
+ ) => ({
78
+ id: "org-1",
79
+ name: "Test Org",
80
+ slug: "test-org",
81
+ createdAt: new Date(),
82
+ ...overrides,
83
+ });
84
+
85
+ const createProvider = (
86
+ overrides: Partial<{
87
+ id: string;
88
+ providerId: string;
89
+ issuer: string;
90
+ domain: string;
91
+ domainVerified: boolean;
92
+ organizationId: string | null;
93
+ userId: string;
94
+ }> = {},
95
+ ) => ({
96
+ id: "provider-1",
97
+ providerId: "test-provider",
98
+ issuer: "https://idp.example.com",
99
+ domain: "example.com",
100
+ domainVerified: false,
101
+ organizationId: "org-1" as string | null,
102
+ userId: "user-1",
103
+ ...overrides,
104
+ });
105
+
106
+ it("should NOT assign user to org when provider domain is unverified", async () => {
107
+ const { data, createContext } = createTestContext();
108
+
109
+ data.organization.push(createOrg());
110
+ data.ssoProvider.push(createProvider({ domainVerified: false }));
111
+
112
+ const user = createUser();
113
+ data.user.push(user);
114
+
115
+ const ctx = (await createContext()) as GenericEndpointContext;
116
+ await assignOrganizationByDomain(ctx, {
117
+ user,
118
+ domainVerification: { enabled: true },
119
+ });
120
+
121
+ const members = data.member.filter((m) => m.userId === user.id);
122
+ expect(members).toHaveLength(0);
123
+ });
124
+
125
+ it("should assign user to org when provider domain is verified", async () => {
126
+ const { data, createContext } = createTestContext();
127
+
128
+ const org = createOrg();
129
+ data.organization.push(org);
130
+ data.ssoProvider.push(
131
+ createProvider({ domainVerified: true, organizationId: org.id }),
132
+ );
133
+
134
+ const user = createUser();
135
+ data.user.push(user);
136
+
137
+ const ctx = (await createContext()) as GenericEndpointContext;
138
+ await assignOrganizationByDomain(ctx, {
139
+ user,
140
+ domainVerification: { enabled: true },
141
+ });
142
+
143
+ const members = data.member.filter((m) => m.userId === user.id);
144
+ expect(members).toHaveLength(1);
145
+ expect(members[0]?.organizationId).toBe(org.id);
146
+ expect(members[0]?.role).toBe("member");
147
+ });
148
+
149
+ it("should NOT assign user when email domain does not match any provider", async () => {
150
+ const { data, createContext } = createTestContext();
151
+
152
+ data.organization.push(createOrg());
153
+ data.ssoProvider.push(createProvider({ domainVerified: true }));
154
+
155
+ const user = createUser({ email: "alice@other-domain.com" });
156
+ data.user.push(user);
157
+
158
+ const ctx = (await createContext()) as GenericEndpointContext;
159
+ await assignOrganizationByDomain(ctx, {
160
+ user,
161
+ domainVerification: { enabled: true },
162
+ });
163
+
164
+ const members = data.member.filter((m) => m.userId === user.id);
165
+ expect(members).toHaveLength(0);
166
+ });
167
+
168
+ it("should NOT assign user when provider has no organizationId", async () => {
169
+ const { data, createContext } = createTestContext();
170
+
171
+ data.ssoProvider.push(
172
+ createProvider({ domainVerified: true, organizationId: null }),
173
+ );
174
+
175
+ const user = createUser();
176
+ data.user.push(user);
177
+
178
+ const ctx = (await createContext()) as GenericEndpointContext;
179
+ await assignOrganizationByDomain(ctx, {
180
+ user,
181
+ domainVerification: { enabled: true },
182
+ });
183
+
184
+ const members = data.member.filter((m) => m.userId === user.id);
185
+ expect(members).toHaveLength(0);
186
+ });
187
+
188
+ it("should NOT assign user when provider has no domainVerified field (verification enabled)", async () => {
189
+ const { data, createContext } = createTestContext();
190
+
191
+ const org = createOrg();
192
+ data.organization.push(org);
193
+
194
+ data.ssoProvider.push({
195
+ id: "provider-1",
196
+ providerId: "test-provider",
197
+ issuer: "https://idp.example.com",
198
+ domain: "example.com",
199
+ organizationId: org.id,
200
+ userId: "user-1",
201
+ } as {
202
+ id: string;
203
+ providerId: string;
204
+ issuer: string;
205
+ domain: string;
206
+ domainVerified: boolean;
207
+ organizationId: string | null;
208
+ userId: string;
209
+ });
210
+
211
+ const user = createUser();
212
+ data.user.push(user);
213
+
214
+ const ctx = (await createContext()) as GenericEndpointContext;
215
+ await assignOrganizationByDomain(ctx, {
216
+ user,
217
+ domainVerification: { enabled: true },
218
+ });
219
+
220
+ const members = data.member.filter((m) => m.userId === user.id);
221
+ expect(members).toHaveLength(0);
222
+ });
223
+
224
+ it("should assign user when verification is disabled (no domainVerified check)", async () => {
225
+ const { data, createContext } = createTestContext();
226
+
227
+ const org = createOrg();
228
+ data.organization.push(org);
229
+ data.ssoProvider.push(
230
+ createProvider({ domainVerified: false, organizationId: org.id }),
231
+ );
232
+
233
+ const user = createUser();
234
+ data.user.push(user);
235
+
236
+ const ctx = (await createContext()) as GenericEndpointContext;
237
+ await assignOrganizationByDomain(ctx, {
238
+ user,
239
+ domainVerification: { enabled: false },
240
+ });
241
+
242
+ const members = data.member.filter((m) => m.userId === user.id);
243
+ expect(members).toHaveLength(1);
244
+ expect(members[0]?.organizationId).toBe(org.id);
245
+ });
246
+
247
+ it("should NOT assign user when already a member of the org", async () => {
248
+ const { data, createContext } = createTestContext();
249
+
250
+ const org = createOrg();
251
+ data.organization.push(org);
252
+ data.ssoProvider.push(
253
+ createProvider({ domainVerified: true, organizationId: org.id }),
254
+ );
255
+
256
+ const user = createUser();
257
+ data.user.push(user);
258
+
259
+ data.member.push({
260
+ id: "member-1",
261
+ organizationId: org.id,
262
+ userId: user.id,
263
+ role: "admin",
264
+ createdAt: new Date(),
265
+ });
266
+
267
+ const ctx = (await createContext()) as GenericEndpointContext;
268
+ await assignOrganizationByDomain(ctx, {
269
+ user,
270
+ domainVerification: { enabled: true },
271
+ });
272
+
273
+ const members = data.member.filter((m) => m.userId === user.id);
274
+ expect(members).toHaveLength(1);
275
+ expect(members[0]?.role).toBe("admin");
276
+ });
277
+
278
+ it("should only find verified provider when multiple providers claim same domain", async () => {
279
+ const { data, createContext } = createTestContext();
280
+
281
+ const legitOrg = createOrg({
282
+ id: "legit-org",
283
+ name: "Legit Org",
284
+ slug: "legit-org",
285
+ });
286
+ const attackerOrg = createOrg({
287
+ id: "attacker-org",
288
+ name: "Attacker Org",
289
+ slug: "attacker-org",
290
+ });
291
+ data.organization.push(legitOrg, attackerOrg);
292
+
293
+ data.ssoProvider.push(
294
+ createProvider({
295
+ id: "attacker-provider",
296
+ providerId: "attacker-provider",
297
+ issuer: "https://attacker.com",
298
+ domainVerified: false,
299
+ organizationId: attackerOrg.id,
300
+ }),
301
+ );
302
+
303
+ data.ssoProvider.push(
304
+ createProvider({
305
+ id: "legit-provider",
306
+ providerId: "legit-provider",
307
+ domainVerified: true,
308
+ organizationId: legitOrg.id,
309
+ }),
310
+ );
311
+
312
+ const user = createUser();
313
+ data.user.push(user);
314
+
315
+ const ctx = (await createContext()) as GenericEndpointContext;
316
+ await assignOrganizationByDomain(ctx, {
317
+ user,
318
+ domainVerification: { enabled: true },
319
+ });
320
+
321
+ const members = data.member.filter((m) => m.userId === user.id);
322
+ expect(members).toHaveLength(1);
323
+ expect(members[0]?.organizationId).toBe(legitOrg.id);
324
+ });
325
+ });
@@ -82,6 +82,9 @@ export async function assignOrganizationFromProvider(
82
82
  export interface AssignOrganizationByDomainOptions {
83
83
  user: User;
84
84
  provisioningOptions?: OrganizationProvisioningOptions;
85
+ domainVerification?: {
86
+ enabled?: boolean;
87
+ };
85
88
  }
86
89
 
87
90
  /**
@@ -96,7 +99,7 @@ export async function assignOrganizationByDomain(
96
99
  ctx: GenericEndpointContext,
97
100
  options: AssignOrganizationByDomainOptions,
98
101
  ): Promise<void> {
99
- const { user, provisioningOptions } = options;
102
+ const { user, provisioningOptions, domainVerification } = options;
100
103
 
101
104
  if (provisioningOptions?.disabled) {
102
105
  return;
@@ -115,11 +118,19 @@ export async function assignOrganizationByDomain(
115
118
  return;
116
119
  }
117
120
 
121
+ const whereClause: { field: string; value: string | boolean }[] = [
122
+ { field: "domain", value: domain },
123
+ ];
124
+
125
+ if (domainVerification?.enabled) {
126
+ whereClause.push({ field: "domainVerified", value: true });
127
+ }
128
+
118
129
  const ssoProvider = await ctx.context.adapter.findOne<
119
130
  SSOProvider<SSOOptions>
120
131
  >({
121
132
  model: "ssoProvider",
122
- where: [{ field: "domain", value: domain }],
133
+ where: whereClause,
123
134
  });
124
135
 
125
136
  if (!ssoProvider || !ssoProvider.organizationId) {