@better-auth/sso 1.4.0-beta.21 → 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
- import { createAuthorizationURL, generateState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
2
1
  import { XMLValidator } from "fast-xml-parser";
3
2
  import * as saml from "samlify";
4
- import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
5
3
  import { APIError, createAuthEndpoint, sessionMiddleware } from "better-auth/api";
4
+ import { generateRandomString } from "better-auth/crypto";
5
+ import * as z from "zod/v4";
6
+ import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
7
+ import { createAuthorizationURL, generateState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
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,7 +715,14 @@ 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") {
720
+ let finalAuthUrl = provider.oidcConfig.authorizationEndpoint;
721
+ if (!finalAuthUrl && provider.oidcConfig.discoveryEndpoint) {
722
+ const discovery = await betterFetch(provider.oidcConfig.discoveryEndpoint, { method: "GET" });
723
+ if (discovery.data) finalAuthUrl = discovery.data.authorization_endpoint;
724
+ }
725
+ if (!finalAuthUrl) throw new APIError("BAD_REQUEST", { message: "Invalid OIDC configuration. Authorization URL not found." });
507
726
  const state = await generateState(ctx, void 0, false);
508
727
  const redirectURI = `${ctx.context.baseURL}/sso/callback/${provider.providerId}`;
509
728
  const authorizationURL = await createAuthorizationURL({
@@ -522,7 +741,7 @@ const signInSSO = (options) => {
522
741
  "offline_access"
523
742
  ],
524
743
  loginHint: ctx.body.loginHint || email,
525
- authorizationEndpoint: provider.oidcConfig.authorizationEndpoint
744
+ authorizationEndpoint: finalAuthUrl
526
745
  });
527
746
  return ctx.json({
528
747
  url: authorizationURL.toString(),
@@ -561,6 +780,7 @@ const callbackSSO = (options) => {
561
780
  error: z.string().optional(),
562
781
  error_description: z.string().optional()
563
782
  }),
783
+ allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
564
784
  metadata: {
565
785
  isAction: false,
566
786
  openapi: {
@@ -585,7 +805,8 @@ const callbackSSO = (options) => {
585
805
  if (matchingDefault) provider = {
586
806
  ...matchingDefault,
587
807
  issuer: matchingDefault.oidcConfig?.issuer || "",
588
- userId: "default"
808
+ userId: "default",
809
+ ...options.domainVerification?.enabled ? { domainVerified: true } : {}
589
810
  };
590
811
  }
591
812
  if (!provider) provider = await ctx.context.adapter.findOne({
@@ -602,6 +823,7 @@ const callbackSSO = (options) => {
602
823
  };
603
824
  });
604
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" });
605
827
  let config = provider.oidcConfig;
606
828
  if (!config) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=provider not found`);
607
829
  const discovery = await betterFetch(config.discoveryEndpoint);
@@ -763,7 +985,8 @@ const callbackSSOSAML = (options) => {
763
985
  if (matchingDefault) provider = {
764
986
  ...matchingDefault,
765
987
  userId: "default",
766
- issuer: matchingDefault.samlConfig?.issuer || ""
988
+ issuer: matchingDefault.samlConfig?.issuer || "",
989
+ ...options.domainVerification?.enabled ? { domainVerified: true } : {}
767
990
  };
768
991
  }
769
992
  if (!provider) provider = await ctx.context.adapter.findOne({
@@ -780,6 +1003,7 @@ const callbackSSOSAML = (options) => {
780
1003
  };
781
1004
  });
782
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" });
783
1007
  const parsedSamlConfig = safeJsonParse(provider.samlConfig);
784
1008
  if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
785
1009
  const idpData = parsedSamlConfig.idpMetadata;
@@ -979,7 +1203,8 @@ const acsEndpoint = (options) => {
979
1203
  providerId: matchingDefault.providerId,
980
1204
  userId: "default",
981
1205
  samlConfig: matchingDefault.samlConfig,
982
- domain: matchingDefault.domain
1206
+ domain: matchingDefault.domain,
1207
+ ...options.domainVerification?.enabled ? { domainVerified: true } : {}
983
1208
  };
984
1209
  } else provider = await ctx.context.adapter.findOne({
985
1210
  model: "ssoProvider",
@@ -995,6 +1220,7 @@ const acsEndpoint = (options) => {
995
1220
  };
996
1221
  });
997
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" });
998
1224
  const parsedSamlConfig = provider.samlConfig;
999
1225
  const sp = saml.ServiceProvider({
1000
1226
  entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
@@ -1095,7 +1321,7 @@ const acsEndpoint = (options) => {
1095
1321
  }
1096
1322
  ]
1097
1323
  })) {
1098
- 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`);
1099
1325
  await ctx.context.internalAdapter.createAccount({
1100
1326
  userId: existingUser.id,
1101
1327
  providerId: provider.providerId,
@@ -1173,50 +1399,75 @@ saml.setSchemaValidator({ async validate(xml) {
1173
1399
  throw "ERR_INVALID_XML";
1174
1400
  } });
1175
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
+ }
1176
1420
  return {
1177
1421
  id: "sso",
1178
- endpoints: {
1179
- spMetadata: spMetadata(),
1180
- registerSSOProvider: registerSSOProvider(options),
1181
- signInSSO: signInSSO(options),
1182
- callbackSSO: callbackSSO(options),
1183
- callbackSSOSAML: callbackSSOSAML(options),
1184
- acsEndpoint: acsEndpoint(options)
1185
- },
1186
- schema: { ssoProvider: { fields: {
1187
- issuer: {
1188
- type: "string",
1189
- required: true
1190
- },
1191
- oidcConfig: {
1192
- type: "string",
1193
- required: false
1194
- },
1195
- samlConfig: {
1196
- type: "string",
1197
- required: false
1198
- },
1199
- userId: {
1200
- type: "string",
1201
- references: {
1202
- model: "user",
1203
- field: "id"
1204
- }
1205
- },
1206
- providerId: {
1207
- type: "string",
1208
- required: true,
1209
- unique: true
1210
- },
1211
- organizationId: {
1212
- type: "string",
1213
- required: false
1214
- },
1215
- domain: {
1216
- type: "string",
1217
- required: true
1422
+ endpoints,
1423
+ schema: { ssoProvider: {
1424
+ modelName: options?.modelName ?? "ssoProvider",
1425
+ fields: {
1426
+ issuer: {
1427
+ type: "string",
1428
+ required: true,
1429
+ fieldName: options?.fields?.issuer ?? "issuer"
1430
+ },
1431
+ oidcConfig: {
1432
+ type: "string",
1433
+ required: false,
1434
+ fieldName: options?.fields?.oidcConfig ?? "oidcConfig"
1435
+ },
1436
+ samlConfig: {
1437
+ type: "string",
1438
+ required: false,
1439
+ fieldName: options?.fields?.samlConfig ?? "samlConfig"
1440
+ },
1441
+ userId: {
1442
+ type: "string",
1443
+ references: {
1444
+ model: "user",
1445
+ field: "id"
1446
+ },
1447
+ fieldName: options?.fields?.userId ?? "userId"
1448
+ },
1449
+ providerId: {
1450
+ type: "string",
1451
+ required: true,
1452
+ unique: true,
1453
+ fieldName: options?.fields?.providerId ?? "providerId"
1454
+ },
1455
+ organizationId: {
1456
+ type: "string",
1457
+ required: false,
1458
+ fieldName: options?.fields?.organizationId ?? "organizationId"
1459
+ },
1460
+ domain: {
1461
+ type: "string",
1462
+ required: true,
1463
+ fieldName: options?.fields?.domain ?? "domain"
1464
+ },
1465
+ ...options?.domainVerification?.enabled ? { domainVerified: {
1466
+ type: "boolean",
1467
+ required: false
1468
+ } } : {}
1218
1469
  }
1219
- } } }
1470
+ } }
1220
1471
  };
1221
1472
  }
1222
1473
 
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.21",
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.21"
68
+ "better-auth": "1.4.0-beta.23"
69
69
  },
70
70
  "peerDependencies": {
71
- "better-auth": "1.4.0-beta.21"
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
  };