@better-auth/sso 1.4.17 → 1.4.19

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.17",
4
+ "version": "1.4.19",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
7
7
  "types": "dist/index.d.mts",
@@ -67,11 +67,12 @@
67
67
  "express": "^5.1.0",
68
68
  "oauth2-mock-server": "^8.2.0",
69
69
  "tsdown": "^0.17.2",
70
- "better-auth": "1.4.17"
70
+ "better-auth": "1.4.19"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "@better-auth/utils": "0.3.0",
74
- "better-auth": "1.4.17"
74
+ "better-call": "1.1.8",
75
+ "better-auth": "1.4.19"
75
76
  },
76
77
  "scripts": {
77
78
  "test": "vitest",
package/src/client.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { BetterAuthClientPlugin } from "better-auth";
1
+ import type { BetterAuthClientPlugin } from "better-auth/client";
2
2
  import type { SSOPlugin } from "./index";
3
3
 
4
4
  interface SSOClientOptions {
@@ -21,5 +21,9 @@ export const ssoClient = <CO extends SSOClientOptions>(
21
21
  : false;
22
22
  };
23
23
  }>,
24
+ pathMethods: {
25
+ "/sso/providers": "GET",
26
+ "/sso/providers/:providerId": "GET",
27
+ },
24
28
  } satisfies BetterAuthClientPlugin;
25
29
  };
@@ -286,7 +286,7 @@ describe("Domain verification", async () => {
286
286
 
287
287
  dnsMock.resolveTxt.mockResolvedValue([
288
288
  [
289
- `better-auth-token-saml-provider-1=${provider.domainVerificationToken}`,
289
+ `_better-auth-token-saml-provider-1=${provider.domainVerificationToken}`,
290
290
  ],
291
291
  ]);
292
292
 
@@ -471,7 +471,7 @@ describe("Domain verification", async () => {
471
471
  "v=spf1 ip4:50.242.118.232/29 include:_spf.google.com include:mail.zendesk.com ~all",
472
472
  ],
473
473
  [
474
- `better-auth-token-saml-provider-1=${provider.domainVerificationToken}`,
474
+ `_better-auth-token-saml-provider-1=${provider.domainVerificationToken}`,
475
475
  ],
476
476
  ]);
477
477
 
@@ -484,6 +484,9 @@ describe("Domain verification", async () => {
484
484
  });
485
485
 
486
486
  expect(response.status).toBe(204);
487
+ expect(dnsMock.resolveTxt).toHaveBeenCalledWith(
488
+ "_better-auth-token-saml-provider-1.hello.com",
489
+ );
487
490
  });
488
491
 
489
492
  it("should verify a provider domain ownership (custom token verification prefix)", async () => {
@@ -498,7 +501,7 @@ describe("Domain verification", async () => {
498
501
  [
499
502
  "v=spf1 ip4:50.242.118.232/29 include:_spf.google.com include:mail.zendesk.com ~all",
500
503
  ],
501
- [`auth-prefix-saml-provider-1=${provider.domainVerificationToken}`],
504
+ [`_auth-prefix-saml-provider-1=${provider.domainVerificationToken}`],
502
505
  ]);
503
506
 
504
507
  const response = await auth.api.verifyDomain({
@@ -510,6 +513,45 @@ describe("Domain verification", async () => {
510
513
  });
511
514
 
512
515
  expect(response.status).toBe(204);
516
+ expect(dnsMock.resolveTxt).toHaveBeenCalledWith(
517
+ "_auth-prefix-saml-provider-1.hello.com",
518
+ );
519
+ });
520
+
521
+ it("should return bad request when provider ID exceeds DNS label limit", async () => {
522
+ const longProviderId = "a".repeat(50);
523
+ const { auth, getAuthHeaders } = createTestAuth();
524
+ const headers = await getAuthHeaders(testUser);
525
+
526
+ await auth.api.registerSSOProvider({
527
+ body: {
528
+ providerId: longProviderId,
529
+ issuer: "http://hello.com:8081",
530
+ domain: "http://hello.com:8081",
531
+ samlConfig: {
532
+ entryPoint: "http://idp.com:",
533
+ cert: "the-cert",
534
+ callbackUrl: "http://hello.com:8081/api/sso/saml2/callback",
535
+ spMetadata: {},
536
+ },
537
+ },
538
+ headers,
539
+ });
540
+
541
+ const response = await auth.api.verifyDomain({
542
+ body: {
543
+ providerId: longProviderId,
544
+ },
545
+ headers,
546
+ asResponse: true,
547
+ });
548
+
549
+ expect(response.status).toBe(400);
550
+ expect(await response.json()).toEqual({
551
+ message:
552
+ "Verification identifier exceeds the DNS label limit of 63 characters",
553
+ code: "IDENTIFIER_TOO_LONG",
554
+ });
513
555
  });
514
556
 
515
557
  it("should fail to verify an already verified domain", async () => {
@@ -519,7 +561,7 @@ describe("Domain verification", async () => {
519
561
 
520
562
  dnsMock.resolveTxt.mockResolvedValue([
521
563
  [
522
- `better-auth-token-saml-provider-1=${provider.domainVerificationToken}`,
564
+ `_better-auth-token-saml-provider-1=${provider.domainVerificationToken}`,
523
565
  ],
524
566
  ]);
525
567
 
package/src/index.ts CHANGED
@@ -7,6 +7,12 @@ import {
7
7
  requestDomainVerification,
8
8
  verifyDomain,
9
9
  } from "./routes/domain-verification";
10
+ import {
11
+ deleteSSOProvider,
12
+ getSSOProvider,
13
+ listSSOProviders,
14
+ updateSSOProvider,
15
+ } from "./routes/providers";
10
16
  import {
11
17
  acsEndpoint,
12
18
  callbackSSO,
@@ -84,6 +90,10 @@ type SSOEndpoints<O extends SSOOptions> = {
84
90
  callbackSSO: ReturnType<typeof callbackSSO>;
85
91
  callbackSSOSAML: ReturnType<typeof callbackSSOSAML>;
86
92
  acsEndpoint: ReturnType<typeof acsEndpoint>;
93
+ listSSOProviders: ReturnType<typeof listSSOProviders>;
94
+ getSSOProvider: ReturnType<typeof getSSOProvider>;
95
+ updateSSOProvider: ReturnType<typeof updateSSOProvider>;
96
+ deleteSSOProvider: ReturnType<typeof deleteSSOProvider>;
87
97
  };
88
98
 
89
99
  export type SSOPlugin<O extends SSOOptions> = {
@@ -94,6 +104,16 @@ export type SSOPlugin<O extends SSOOptions> = {
94
104
  : {});
95
105
  };
96
106
 
107
+ /**
108
+ * SAML endpoint paths that should skip origin check validation.
109
+ * These endpoints receive POST requests from external Identity Providers,
110
+ * which won't have a matching Origin header.
111
+ */
112
+ const SAML_SKIP_ORIGIN_CHECK_PATHS = [
113
+ "/sso/saml2/callback", // SP-initiated SSO callback (prefix matches /callback/:providerId)
114
+ "/sso/saml2/sp/acs", // IdP-initiated SSO ACS (prefix matches /sp/acs/:providerId)
115
+ ];
116
+
97
117
  export function sso<
98
118
  O extends SSOOptions & {
99
119
  domainVerification?: { enabled: true };
@@ -103,7 +123,7 @@ export function sso<
103
123
  ): {
104
124
  id: "sso";
105
125
  endpoints: SSOEndpoints<O> & DomainVerificationEndpoints;
106
- schema: any;
126
+ schema: NonNullable<BetterAuthPlugin["schema"]>;
107
127
  options: O;
108
128
  };
109
129
  export function sso<O extends SSOOptions>(
@@ -113,7 +133,9 @@ export function sso<O extends SSOOptions>(
113
133
  endpoints: SSOEndpoints<O>;
114
134
  };
115
135
 
116
- export function sso<O extends SSOOptions>(options?: O | undefined): any {
136
+ export function sso<O extends SSOOptions>(
137
+ options?: O | undefined,
138
+ ): BetterAuthPlugin {
117
139
  const optionsWithStore = options as O;
118
140
 
119
141
  let endpoints = {
@@ -123,6 +145,10 @@ export function sso<O extends SSOOptions>(options?: O | undefined): any {
123
145
  callbackSSO: callbackSSO(optionsWithStore),
124
146
  callbackSSOSAML: callbackSSOSAML(optionsWithStore),
125
147
  acsEndpoint: acsEndpoint(optionsWithStore),
148
+ listSSOProviders: listSSOProviders(),
149
+ getSSOProvider: getSSOProvider(),
150
+ updateSSOProvider: updateSSOProvider(optionsWithStore),
151
+ deleteSSOProvider: deleteSSOProvider(),
126
152
  };
127
153
 
128
154
  if (options?.domainVerification?.enabled) {
@@ -139,6 +165,18 @@ export function sso<O extends SSOOptions>(options?: O | undefined): any {
139
165
 
140
166
  return {
141
167
  id: "sso",
168
+ init(ctx) {
169
+ const existing = ctx.skipOriginCheck;
170
+ if (existing === true) {
171
+ return {};
172
+ }
173
+ const existingPaths = Array.isArray(existing) ? existing : [];
174
+ return {
175
+ context: {
176
+ skipOriginCheck: [...existingPaths, ...SAML_SKIP_ORIGIN_CHECK_PATHS],
177
+ },
178
+ };
179
+ },
142
180
  endpoints,
143
181
  hooks: {
144
182
  after: [
@@ -152,10 +190,11 @@ export function sso<O extends SSOOptions>(options?: O | undefined): any {
152
190
  return;
153
191
  }
154
192
 
155
- const isOrgPluginEnabled = ctx.context.options.plugins?.find(
156
- (plugin: { id: string }) => plugin.id === "organization",
157
- );
158
- if (!isOrgPluginEnabled) {
193
+ const hasOrganizationPlugin =
194
+ ctx.context.options.plugins?.some(
195
+ (plugin) => plugin.id === "organization",
196
+ ) ?? false;
197
+ if (!hasOrganizationPlugin) {
159
198
  return;
160
199
  }
161
200
 
@@ -1,16 +1,17 @@
1
1
  import type { GenericEndpointContext, OAuth2Tokens, User } from "better-auth";
2
2
  import type { SSOOptions, SSOProvider } from "../types";
3
+ import { domainMatches } from "../utils";
3
4
  import type { NormalizedSSOProfile } from "./types";
4
5
 
5
6
  export interface OrganizationProvisioningOptions {
6
7
  disabled?: boolean;
7
- defaultRole?: "member" | "admin";
8
+ defaultRole?: string;
8
9
  getRole?: (data: {
9
10
  user: User & Record<string, any>;
10
11
  userInfo: Record<string, any>;
11
12
  token?: OAuth2Tokens;
12
13
  provider: SSOProvider<SSOOptions>;
13
- }) => Promise<"member" | "admin">;
14
+ }) => Promise<string>;
14
15
  }
15
16
 
16
17
  export interface AssignOrganizationFromProviderOptions {
@@ -39,11 +40,11 @@ export async function assignOrganizationFromProvider(
39
40
  return;
40
41
  }
41
42
 
42
- const isOrgPluginEnabled = ctx.context.options.plugins?.find(
43
- (plugin) => plugin.id === "organization",
44
- );
45
-
46
- if (!isOrgPluginEnabled) {
43
+ const hasOrganizationPlugin =
44
+ ctx.context.options.plugins?.some(
45
+ (plugin) => plugin.id === "organization",
46
+ ) ?? false;
47
+ if (!hasOrganizationPlugin) {
47
48
  return;
48
49
  }
49
50
 
@@ -105,11 +106,11 @@ export async function assignOrganizationByDomain(
105
106
  return;
106
107
  }
107
108
 
108
- const isOrgPluginEnabled = ctx.context.options.plugins?.find(
109
- (plugin) => plugin.id === "organization",
110
- );
111
-
112
- if (!isOrgPluginEnabled) {
109
+ const hasOrganizationPlugin =
110
+ ctx.context.options.plugins?.some(
111
+ (plugin) => plugin.id === "organization",
112
+ ) ?? false;
113
+ if (!hasOrganizationPlugin) {
113
114
  return;
114
115
  }
115
116
 
@@ -118,6 +119,8 @@ export async function assignOrganizationByDomain(
118
119
  return;
119
120
  }
120
121
 
122
+ // Support comma-separated domains for multi-domain SSO
123
+ // First try exact match (fast path)
121
124
  const whereClause: { field: string; value: string | boolean }[] = [
122
125
  { field: "domain", value: domain },
123
126
  ];
@@ -126,13 +129,25 @@ export async function assignOrganizationByDomain(
126
129
  whereClause.push({ field: "domainVerified", value: true });
127
130
  }
128
131
 
129
- const ssoProvider = await ctx.context.adapter.findOne<
130
- SSOProvider<SSOOptions>
131
- >({
132
+ let ssoProvider = await ctx.context.adapter.findOne<SSOProvider<SSOOptions>>({
132
133
  model: "ssoProvider",
133
134
  where: whereClause,
134
135
  });
135
136
 
137
+ // If not found, search all providers for comma-separated domain match
138
+ if (!ssoProvider) {
139
+ const allProviders = await ctx.context.adapter.findMany<
140
+ SSOProvider<SSOOptions>
141
+ >({
142
+ model: "ssoProvider",
143
+ where: domainVerification?.enabled
144
+ ? [{ field: "domainVerified", value: true }]
145
+ : [],
146
+ });
147
+ ssoProvider =
148
+ allProviders.find((p) => domainMatches(domain, p.domain)) ?? null;
149
+ }
150
+
136
151
  if (!ssoProvider || !ssoProvider.organizationId) {
137
152
  return;
138
153
  }
package/src/oidc.test.ts CHANGED
@@ -393,9 +393,7 @@ describe("SSO disable implicit sign in", async () => {
393
393
  "redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fsso%2Fcallback%2Ftest",
394
394
  );
395
395
  const { callbackURL } = await simulateOAuthFlow(res.url, headers);
396
- expect(callbackURL).toContain(
397
- "/api/auth/error/error?error=signup disabled",
398
- );
396
+ expect(callbackURL).toContain("/api/auth/error?error=signup disabled");
399
397
  });
400
398
 
401
399
  it("should create user with SSO provider when sign ups are disabled but sign up is requested", async () => {