@better-auth/sso 1.4.0-beta.22 → 1.4.0-beta.23

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/dist/index.mjs CHANGED
@@ -1,13 +1,197 @@
1
1
  import { XMLValidator } from "fast-xml-parser";
2
2
  import * as saml from "samlify";
3
+ import { APIError, createAuthEndpoint, sessionMiddleware } from "better-auth/api";
4
+ import { generateRandomString } from "better-auth/crypto";
5
+ import * as z from "zod/v4";
3
6
  import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
4
7
  import { createAuthorizationURL, generateState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
5
- import { APIError, createAuthEndpoint, sessionMiddleware } from "better-auth/api";
6
8
  import { setSessionCookie } from "better-auth/cookies";
7
9
  import { handleOAuthUserInfo } from "better-auth/oauth2";
8
10
  import { decodeJwt } from "jose";
9
- import * as z from "zod/v4";
10
11
 
12
+ //#region src/routes/domain-verification.ts
13
+ const requestDomainVerification = (options) => {
14
+ return createAuthEndpoint("/sso/request-domain-verification", {
15
+ method: "POST",
16
+ body: z.object({ providerId: z.string() }),
17
+ metadata: { openapi: {
18
+ summary: "Request a domain verification",
19
+ description: "Request a domain verification for the given SSO provider",
20
+ responses: {
21
+ "404": { description: "Provider not found" },
22
+ "409": { description: "Domain has already been verified" },
23
+ "201": { description: "Domain submitted for verification" }
24
+ }
25
+ } },
26
+ use: [sessionMiddleware]
27
+ }, async (ctx) => {
28
+ const body = ctx.body;
29
+ const provider = await ctx.context.adapter.findOne({
30
+ model: "ssoProvider",
31
+ where: [{
32
+ field: "providerId",
33
+ value: body.providerId
34
+ }]
35
+ });
36
+ if (!provider) throw new APIError("NOT_FOUND", {
37
+ message: "Provider not found",
38
+ code: "PROVIDER_NOT_FOUND"
39
+ });
40
+ const userId = ctx.context.session.user.id;
41
+ let isOrgMember = true;
42
+ if (provider.organizationId) isOrgMember = await ctx.context.adapter.count({
43
+ model: "member",
44
+ where: [{
45
+ field: "userId",
46
+ value: userId
47
+ }, {
48
+ field: "organizationId",
49
+ value: provider.organizationId
50
+ }]
51
+ }) > 0;
52
+ if (provider.userId !== userId || !isOrgMember) throw new APIError("FORBIDDEN", {
53
+ message: "User must be owner of or belong to the SSO provider organization",
54
+ code: "INSUFICCIENT_ACCESS"
55
+ });
56
+ if ("domainVerified" in provider && provider.domainVerified) throw new APIError("CONFLICT", {
57
+ message: "Domain has already been verified",
58
+ code: "DOMAIN_VERIFIED"
59
+ });
60
+ const activeVerification = await ctx.context.adapter.findOne({
61
+ model: "verification",
62
+ where: [{
63
+ field: "identifier",
64
+ value: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`
65
+ }, {
66
+ field: "expiresAt",
67
+ value: /* @__PURE__ */ new Date(),
68
+ operator: "gt"
69
+ }]
70
+ });
71
+ if (activeVerification) {
72
+ ctx.setStatus(201);
73
+ return ctx.json({ domainVerificationToken: activeVerification.value });
74
+ }
75
+ const domainVerificationToken = generateRandomString(24);
76
+ await ctx.context.adapter.create({
77
+ model: "verification",
78
+ data: {
79
+ identifier: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`,
80
+ createdAt: /* @__PURE__ */ new Date(),
81
+ updatedAt: /* @__PURE__ */ new Date(),
82
+ value: domainVerificationToken,
83
+ expiresAt: new Date(Date.now() + 3600 * 24 * 7 * 1e3)
84
+ }
85
+ });
86
+ ctx.setStatus(201);
87
+ return ctx.json({ domainVerificationToken });
88
+ });
89
+ };
90
+ const verifyDomain = (options) => {
91
+ return createAuthEndpoint("/sso/verify-domain", {
92
+ method: "POST",
93
+ body: z.object({ providerId: z.string() }),
94
+ metadata: { openapi: {
95
+ summary: "Verify the provider domain ownership",
96
+ description: "Verify the provider domain ownership via DNS records",
97
+ responses: {
98
+ "404": { description: "Provider not found" },
99
+ "409": { description: "Domain has already been verified or no pending verification exists" },
100
+ "502": { description: "Unable to verify domain ownership due to upstream validator error" },
101
+ "204": { description: "Domain ownership was verified" }
102
+ }
103
+ } },
104
+ use: [sessionMiddleware]
105
+ }, async (ctx) => {
106
+ const body = ctx.body;
107
+ const provider = await ctx.context.adapter.findOne({
108
+ model: "ssoProvider",
109
+ where: [{
110
+ field: "providerId",
111
+ value: body.providerId
112
+ }]
113
+ });
114
+ if (!provider) throw new APIError("NOT_FOUND", {
115
+ message: "Provider not found",
116
+ code: "PROVIDER_NOT_FOUND"
117
+ });
118
+ const userId = ctx.context.session.user.id;
119
+ let isOrgMember = true;
120
+ if (provider.organizationId) isOrgMember = await ctx.context.adapter.count({
121
+ model: "member",
122
+ where: [{
123
+ field: "userId",
124
+ value: userId
125
+ }, {
126
+ field: "organizationId",
127
+ value: provider.organizationId
128
+ }]
129
+ }) > 0;
130
+ if (provider.userId !== userId || !isOrgMember) throw new APIError("FORBIDDEN", {
131
+ message: "User must be owner of or belong to the SSO provider organization",
132
+ code: "INSUFICCIENT_ACCESS"
133
+ });
134
+ if ("domainVerified" in provider && provider.domainVerified) throw new APIError("CONFLICT", {
135
+ message: "Domain has already been verified",
136
+ code: "DOMAIN_VERIFIED"
137
+ });
138
+ const activeVerification = await ctx.context.adapter.findOne({
139
+ model: "verification",
140
+ where: [{
141
+ field: "identifier",
142
+ value: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`
143
+ }, {
144
+ field: "expiresAt",
145
+ value: /* @__PURE__ */ new Date(),
146
+ operator: "gt"
147
+ }]
148
+ });
149
+ if (!activeVerification) throw new APIError("NOT_FOUND", {
150
+ message: "No pending domain verification exists",
151
+ code: "NO_PENDING_VERIFICATION"
152
+ });
153
+ let records = [];
154
+ let dns;
155
+ try {
156
+ dns = await import("node:dns/promises");
157
+ } catch (error) {
158
+ ctx.context.logger.error("The core node:dns module is required for the domain verification feature", error);
159
+ throw new APIError("INTERNAL_SERVER_ERROR", {
160
+ message: "Unable to verify domain ownership due to server error",
161
+ code: "DOMAIN_VERIFICATION_FAILED"
162
+ });
163
+ }
164
+ try {
165
+ records = (await dns.resolveTxt(new URL(provider.domain).hostname)).flat();
166
+ } catch (error) {
167
+ ctx.context.logger.warn("DNS resolution failure while validating domain ownership", error);
168
+ }
169
+ if (!records.find((record) => record.includes(`${activeVerification.identifier}=${activeVerification.value}`))) throw new APIError("BAD_GATEWAY", {
170
+ message: "Unable to verify domain ownership. Try again later",
171
+ code: "DOMAIN_VERIFICATION_FAILED"
172
+ });
173
+ await ctx.context.adapter.update({
174
+ model: "ssoProvider",
175
+ where: [{
176
+ field: "providerId",
177
+ value: provider.providerId
178
+ }],
179
+ update: { domainVerified: true }
180
+ });
181
+ ctx.setStatus(204);
182
+ });
183
+ };
184
+
185
+ //#endregion
186
+ //#region src/utils.ts
187
+ const validateEmailDomain = (email, domain) => {
188
+ const emailDomain = email.split("@")[1]?.toLowerCase();
189
+ const providerDomain = domain.toLowerCase();
190
+ if (!emailDomain || !providerDomain) return false;
191
+ return emailDomain === providerDomain || emailDomain.endsWith(`.${providerDomain}`);
192
+ };
193
+
194
+ //#endregion
11
195
  //#region src/routes/sso.ts
12
196
  /**
13
197
  * Safely parses a value that might be a JSON string or already a parsed object
@@ -155,6 +339,14 @@ const registerSSOProvider = (options) => {
155
339
  type: "string",
156
340
  description: "The domain of the provider, used for email matching"
157
341
  },
342
+ domainVerified: {
343
+ type: "boolean",
344
+ description: "A boolean indicating whether the domain has been verified or not"
345
+ },
346
+ domainVerificationToken: {
347
+ type: "string",
348
+ description: "Domain verification token. It can be used to prove ownership over the SSO domain"
349
+ },
158
350
  oidcConfig: {
159
351
  type: "object",
160
352
  properties: {
@@ -336,6 +528,7 @@ const registerSSOProvider = (options) => {
336
528
  data: {
337
529
  issuer: body.issuer,
338
530
  domain: body.domain,
531
+ domainVerified: false,
339
532
  oidcConfig: body.oidcConfig ? JSON.stringify({
340
533
  issuer: body.issuer,
341
534
  clientId: body.oidcConfig.clientId,
@@ -373,11 +566,29 @@ const registerSSOProvider = (options) => {
373
566
  providerId: body.providerId
374
567
  }
375
568
  });
569
+ let domainVerificationToken;
570
+ let domainVerified;
571
+ if (options?.domainVerification?.enabled) {
572
+ domainVerified = false;
573
+ domainVerificationToken = generateRandomString(24);
574
+ await ctx.context.adapter.create({
575
+ model: "verification",
576
+ data: {
577
+ identifier: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`,
578
+ createdAt: /* @__PURE__ */ new Date(),
579
+ updatedAt: /* @__PURE__ */ new Date(),
580
+ value: domainVerificationToken,
581
+ expiresAt: new Date(Date.now() + 3600 * 24 * 7 * 1e3)
582
+ }
583
+ });
584
+ }
376
585
  return ctx.json({
377
586
  ...provider,
378
587
  oidcConfig: JSON.parse(provider.oidcConfig),
379
588
  samlConfig: JSON.parse(provider.samlConfig),
380
- redirectURI: `${ctx.context.baseURL}/sso/callback/${provider.providerId}`
589
+ redirectURI: `${ctx.context.baseURL}/sso/callback/${provider.providerId}`,
590
+ ...options?.domainVerification?.enabled ? { domainVerified } : {},
591
+ ...options?.domainVerification?.enabled ? { domainVerificationToken } : {}
381
592
  });
382
593
  });
383
594
  };
@@ -480,7 +691,8 @@ const signInSSO = (options) => {
480
691
  userId: "default",
481
692
  oidcConfig: matchingDefault.oidcConfig,
482
693
  samlConfig: matchingDefault.samlConfig,
483
- domain: matchingDefault.domain
694
+ domain: matchingDefault.domain,
695
+ ...options.domainVerification?.enabled ? { domainVerified: true } : {}
484
696
  };
485
697
  }
486
698
  if (!providerId && !orgId && !domain) throw new APIError("BAD_REQUEST", { message: "providerId, orgId or domain is required" });
@@ -503,6 +715,7 @@ const signInSSO = (options) => {
503
715
  if (body.providerType === "oidc" && !provider.oidcConfig) throw new APIError("BAD_REQUEST", { message: "OIDC provider is not configured" });
504
716
  if (body.providerType === "saml" && !provider.samlConfig) throw new APIError("BAD_REQUEST", { message: "SAML provider is not configured" });
505
717
  }
718
+ if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
506
719
  if (provider.oidcConfig && body.providerType !== "saml") {
507
720
  let finalAuthUrl = provider.oidcConfig.authorizationEndpoint;
508
721
  if (!finalAuthUrl && provider.oidcConfig.discoveryEndpoint) {
@@ -567,6 +780,7 @@ const callbackSSO = (options) => {
567
780
  error: z.string().optional(),
568
781
  error_description: z.string().optional()
569
782
  }),
783
+ allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
570
784
  metadata: {
571
785
  isAction: false,
572
786
  openapi: {
@@ -591,7 +805,8 @@ const callbackSSO = (options) => {
591
805
  if (matchingDefault) provider = {
592
806
  ...matchingDefault,
593
807
  issuer: matchingDefault.oidcConfig?.issuer || "",
594
- userId: "default"
808
+ userId: "default",
809
+ ...options.domainVerification?.enabled ? { domainVerified: true } : {}
595
810
  };
596
811
  }
597
812
  if (!provider) provider = await ctx.context.adapter.findOne({
@@ -608,6 +823,7 @@ const callbackSSO = (options) => {
608
823
  };
609
824
  });
610
825
  if (!provider) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=provider not found`);
826
+ if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
611
827
  let config = provider.oidcConfig;
612
828
  if (!config) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=provider not found`);
613
829
  const discovery = await betterFetch(config.discoveryEndpoint);
@@ -769,7 +985,8 @@ const callbackSSOSAML = (options) => {
769
985
  if (matchingDefault) provider = {
770
986
  ...matchingDefault,
771
987
  userId: "default",
772
- issuer: matchingDefault.samlConfig?.issuer || ""
988
+ issuer: matchingDefault.samlConfig?.issuer || "",
989
+ ...options.domainVerification?.enabled ? { domainVerified: true } : {}
773
990
  };
774
991
  }
775
992
  if (!provider) provider = await ctx.context.adapter.findOne({
@@ -786,6 +1003,7 @@ const callbackSSOSAML = (options) => {
786
1003
  };
787
1004
  });
788
1005
  if (!provider) throw new APIError("NOT_FOUND", { message: "No provider found for the given providerId" });
1006
+ if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
789
1007
  const parsedSamlConfig = safeJsonParse(provider.samlConfig);
790
1008
  if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
791
1009
  const idpData = parsedSamlConfig.idpMetadata;
@@ -985,7 +1203,8 @@ const acsEndpoint = (options) => {
985
1203
  providerId: matchingDefault.providerId,
986
1204
  userId: "default",
987
1205
  samlConfig: matchingDefault.samlConfig,
988
- domain: matchingDefault.domain
1206
+ domain: matchingDefault.domain,
1207
+ ...options.domainVerification?.enabled ? { domainVerified: true } : {}
989
1208
  };
990
1209
  } else provider = await ctx.context.adapter.findOne({
991
1210
  model: "ssoProvider",
@@ -1001,6 +1220,7 @@ const acsEndpoint = (options) => {
1001
1220
  };
1002
1221
  });
1003
1222
  if (!provider?.samlConfig) throw new APIError("NOT_FOUND", { message: "No SAML provider found" });
1223
+ if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
1004
1224
  const parsedSamlConfig = provider.samlConfig;
1005
1225
  const sp = saml.ServiceProvider({
1006
1226
  entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
@@ -1101,7 +1321,7 @@ const acsEndpoint = (options) => {
1101
1321
  }
1102
1322
  ]
1103
1323
  })) {
1104
- if (!ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId)) throw ctx.redirect(`${parsedSamlConfig.callbackUrl}?error=account_not_found`);
1324
+ if (!(ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain))) throw ctx.redirect(`${parsedSamlConfig.callbackUrl}?error=account_not_found`);
1105
1325
  await ctx.context.internalAdapter.createAccount({
1106
1326
  userId: existingUser.id,
1107
1327
  providerId: provider.providerId,
@@ -1179,16 +1399,27 @@ saml.setSchemaValidator({ async validate(xml) {
1179
1399
  throw "ERR_INVALID_XML";
1180
1400
  } });
1181
1401
  function sso(options) {
1402
+ let endpoints = {
1403
+ spMetadata: spMetadata(),
1404
+ registerSSOProvider: registerSSOProvider(options),
1405
+ signInSSO: signInSSO(options),
1406
+ callbackSSO: callbackSSO(options),
1407
+ callbackSSOSAML: callbackSSOSAML(options),
1408
+ acsEndpoint: acsEndpoint(options)
1409
+ };
1410
+ if (options?.domainVerification?.enabled) {
1411
+ const domainVerificationEndpoints = {
1412
+ requestDomainVerification: requestDomainVerification(options),
1413
+ verifyDomain: verifyDomain(options)
1414
+ };
1415
+ endpoints = {
1416
+ ...endpoints,
1417
+ ...domainVerificationEndpoints
1418
+ };
1419
+ }
1182
1420
  return {
1183
1421
  id: "sso",
1184
- endpoints: {
1185
- spMetadata: spMetadata(),
1186
- registerSSOProvider: registerSSOProvider(options),
1187
- signInSSO: signInSSO(options),
1188
- callbackSSO: callbackSSO(options),
1189
- callbackSSOSAML: callbackSSOSAML(options),
1190
- acsEndpoint: acsEndpoint(options)
1191
- },
1422
+ endpoints,
1192
1423
  schema: { ssoProvider: {
1193
1424
  modelName: options?.modelName ?? "ssoProvider",
1194
1425
  fields: {
@@ -1230,7 +1461,11 @@ function sso(options) {
1230
1461
  type: "string",
1231
1462
  required: true,
1232
1463
  fieldName: options?.fields?.domain ?? "domain"
1233
- }
1464
+ },
1465
+ ...options?.domainVerification?.enabled ? { domainVerified: {
1466
+ type: "boolean",
1467
+ required: false
1468
+ } } : {}
1234
1469
  }
1235
1470
  } }
1236
1471
  };
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.0-beta.22",
4
+ "version": "1.4.0-beta.23",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
7
7
  "homepage": "https://www.better-auth.com/docs/plugins/sso",
@@ -60,15 +60,15 @@
60
60
  "devDependencies": {
61
61
  "@types/body-parser": "^1.19.6",
62
62
  "@types/express": "^5.0.5",
63
- "better-call": "1.0.26",
63
+ "better-call": "1.0.28",
64
64
  "body-parser": "^2.2.0",
65
65
  "express": "^5.1.0",
66
66
  "oauth2-mock-server": "^7.2.1",
67
67
  "tsdown": "^0.16.0",
68
- "better-auth": "1.4.0-beta.22"
68
+ "better-auth": "1.4.0-beta.23"
69
69
  },
70
70
  "peerDependencies": {
71
- "better-auth": "1.4.0-beta.22"
71
+ "better-auth": "1.4.0-beta.23"
72
72
  },
73
73
  "scripts": {
74
74
  "test": "vitest",
package/src/client.ts CHANGED
@@ -1,8 +1,25 @@
1
1
  import type { BetterAuthClientPlugin } from "better-auth";
2
- import type { sso } from "./index";
3
- export const ssoClient = () => {
2
+ import type { SSOPlugin } from "./index";
3
+
4
+ interface SSOClientOptions {
5
+ domainVerification?:
6
+ | {
7
+ enabled: boolean;
8
+ }
9
+ | undefined;
10
+ }
11
+
12
+ export const ssoClient = <CO extends SSOClientOptions>(
13
+ options?: CO | undefined,
14
+ ) => {
4
15
  return {
5
16
  id: "sso-client",
6
- $InferServerPlugin: {} as ReturnType<typeof sso>,
17
+ $InferServerPlugin: {} as SSOPlugin<{
18
+ domainVerification: {
19
+ enabled: CO["domainVerification"] extends { enabled: true }
20
+ ? true
21
+ : false;
22
+ };
23
+ }>,
7
24
  } satisfies BetterAuthClientPlugin;
8
25
  };