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

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.
@@ -0,0 +1,275 @@
1
+ import type { Verification } from "better-auth";
2
+ import {
3
+ APIError,
4
+ createAuthEndpoint,
5
+ sessionMiddleware,
6
+ } from "better-auth/api";
7
+ import { generateRandomString } from "better-auth/crypto";
8
+ import * as z from "zod/v4";
9
+ import type { SSOOptions, SSOProvider } from "../types";
10
+
11
+ export const requestDomainVerification = (options: SSOOptions) => {
12
+ return createAuthEndpoint(
13
+ "/sso/request-domain-verification",
14
+ {
15
+ method: "POST",
16
+ body: z.object({
17
+ providerId: z.string(),
18
+ }),
19
+ metadata: {
20
+ openapi: {
21
+ summary: "Request a domain verification",
22
+ description:
23
+ "Request a domain verification for the given SSO provider",
24
+ responses: {
25
+ "404": {
26
+ description: "Provider not found",
27
+ },
28
+ "409": {
29
+ description: "Domain has already been verified",
30
+ },
31
+ "201": {
32
+ description: "Domain submitted for verification",
33
+ },
34
+ },
35
+ },
36
+ },
37
+ use: [sessionMiddleware],
38
+ },
39
+ async (ctx) => {
40
+ const body = ctx.body;
41
+ const provider = await ctx.context.adapter.findOne<
42
+ SSOProvider<SSOOptions>
43
+ >({
44
+ model: "ssoProvider",
45
+ where: [{ field: "providerId", value: body.providerId }],
46
+ });
47
+
48
+ if (!provider) {
49
+ throw new APIError("NOT_FOUND", {
50
+ message: "Provider not found",
51
+ code: "PROVIDER_NOT_FOUND",
52
+ });
53
+ }
54
+
55
+ const userId = ctx.context.session.user.id;
56
+ let isOrgMember = true;
57
+ if (provider.organizationId) {
58
+ const membershipsCount = await ctx.context.adapter.count({
59
+ model: "member",
60
+ where: [
61
+ { field: "userId", value: userId },
62
+ { field: "organizationId", value: provider.organizationId },
63
+ ],
64
+ });
65
+
66
+ isOrgMember = membershipsCount > 0;
67
+ }
68
+
69
+ if (provider.userId !== userId || !isOrgMember) {
70
+ throw new APIError("FORBIDDEN", {
71
+ message:
72
+ "User must be owner of or belong to the SSO provider organization",
73
+ code: "INSUFICCIENT_ACCESS",
74
+ });
75
+ }
76
+
77
+ if ("domainVerified" in provider && provider.domainVerified) {
78
+ throw new APIError("CONFLICT", {
79
+ message: "Domain has already been verified",
80
+ code: "DOMAIN_VERIFIED",
81
+ });
82
+ }
83
+
84
+ const activeVerification =
85
+ await ctx.context.adapter.findOne<Verification>({
86
+ model: "verification",
87
+ where: [
88
+ {
89
+ field: "identifier",
90
+ value: options.domainVerification?.tokenPrefix
91
+ ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}`
92
+ : `better-auth-token-${provider.providerId}`,
93
+ },
94
+ { field: "expiresAt", value: new Date(), operator: "gt" },
95
+ ],
96
+ });
97
+
98
+ if (activeVerification) {
99
+ ctx.setStatus(201);
100
+ return ctx.json({ domainVerificationToken: activeVerification.value });
101
+ }
102
+
103
+ const domainVerificationToken = generateRandomString(24);
104
+ await ctx.context.adapter.create<Verification>({
105
+ model: "verification",
106
+ data: {
107
+ identifier: options.domainVerification?.tokenPrefix
108
+ ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}`
109
+ : `better-auth-token-${provider.providerId}`,
110
+ createdAt: new Date(),
111
+ updatedAt: new Date(),
112
+ value: domainVerificationToken,
113
+ expiresAt: new Date(Date.now() + 3600 * 24 * 7 * 1000), // 1 week
114
+ },
115
+ });
116
+
117
+ ctx.setStatus(201);
118
+ return ctx.json({
119
+ domainVerificationToken,
120
+ });
121
+ },
122
+ );
123
+ };
124
+
125
+ export const verifyDomain = (options: SSOOptions) => {
126
+ return createAuthEndpoint(
127
+ "/sso/verify-domain",
128
+ {
129
+ method: "POST",
130
+ body: z.object({
131
+ providerId: z.string(),
132
+ }),
133
+ metadata: {
134
+ openapi: {
135
+ summary: "Verify the provider domain ownership",
136
+ description: "Verify the provider domain ownership via DNS records",
137
+ responses: {
138
+ "404": {
139
+ description: "Provider not found",
140
+ },
141
+ "409": {
142
+ description:
143
+ "Domain has already been verified or no pending verification exists",
144
+ },
145
+ "502": {
146
+ description:
147
+ "Unable to verify domain ownership due to upstream validator error",
148
+ },
149
+ "204": {
150
+ description: "Domain ownership was verified",
151
+ },
152
+ },
153
+ },
154
+ },
155
+ use: [sessionMiddleware],
156
+ },
157
+ async (ctx) => {
158
+ const body = ctx.body;
159
+ const provider = await ctx.context.adapter.findOne<
160
+ SSOProvider<SSOOptions>
161
+ >({
162
+ model: "ssoProvider",
163
+ where: [{ field: "providerId", value: body.providerId }],
164
+ });
165
+
166
+ if (!provider) {
167
+ throw new APIError("NOT_FOUND", {
168
+ message: "Provider not found",
169
+ code: "PROVIDER_NOT_FOUND",
170
+ });
171
+ }
172
+
173
+ const userId = ctx.context.session.user.id;
174
+ let isOrgMember = true;
175
+ if (provider.organizationId) {
176
+ const membershipsCount = await ctx.context.adapter.count({
177
+ model: "member",
178
+ where: [
179
+ { field: "userId", value: userId },
180
+ { field: "organizationId", value: provider.organizationId },
181
+ ],
182
+ });
183
+
184
+ isOrgMember = membershipsCount > 0;
185
+ }
186
+
187
+ if (provider.userId !== userId || !isOrgMember) {
188
+ throw new APIError("FORBIDDEN", {
189
+ message:
190
+ "User must be owner of or belong to the SSO provider organization",
191
+ code: "INSUFICCIENT_ACCESS",
192
+ });
193
+ }
194
+
195
+ if ("domainVerified" in provider && provider.domainVerified) {
196
+ throw new APIError("CONFLICT", {
197
+ message: "Domain has already been verified",
198
+ code: "DOMAIN_VERIFIED",
199
+ });
200
+ }
201
+
202
+ const activeVerification =
203
+ await ctx.context.adapter.findOne<Verification>({
204
+ model: "verification",
205
+ where: [
206
+ {
207
+ field: "identifier",
208
+ value: options.domainVerification?.tokenPrefix
209
+ ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}`
210
+ : `better-auth-token-${provider.providerId}`,
211
+ },
212
+ { field: "expiresAt", value: new Date(), operator: "gt" },
213
+ ],
214
+ });
215
+
216
+ if (!activeVerification) {
217
+ throw new APIError("NOT_FOUND", {
218
+ message: "No pending domain verification exists",
219
+ code: "NO_PENDING_VERIFICATION",
220
+ });
221
+ }
222
+
223
+ let records: string[] = [];
224
+ let dns: typeof import("node:dns/promises");
225
+
226
+ try {
227
+ dns = await import("node:dns/promises");
228
+ } catch (error) {
229
+ ctx.context.logger.error(
230
+ "The core node:dns module is required for the domain verification feature",
231
+ error,
232
+ );
233
+ throw new APIError("INTERNAL_SERVER_ERROR", {
234
+ message: "Unable to verify domain ownership due to server error",
235
+ code: "DOMAIN_VERIFICATION_FAILED",
236
+ });
237
+ }
238
+
239
+ try {
240
+ const dnsRecords = await dns.resolveTxt(
241
+ new URL(provider.domain).hostname,
242
+ );
243
+ records = dnsRecords.flat();
244
+ } catch (error) {
245
+ ctx.context.logger.warn(
246
+ "DNS resolution failure while validating domain ownership",
247
+ error,
248
+ );
249
+ }
250
+
251
+ const record = records.find((record) =>
252
+ record.includes(
253
+ `${activeVerification.identifier}=${activeVerification.value}`,
254
+ ),
255
+ );
256
+ if (!record) {
257
+ throw new APIError("BAD_GATEWAY", {
258
+ message: "Unable to verify domain ownership. Try again later",
259
+ code: "DOMAIN_VERIFICATION_FAILED",
260
+ });
261
+ }
262
+
263
+ await ctx.context.adapter.update<SSOProvider<SSOOptions>>({
264
+ model: "ssoProvider",
265
+ where: [{ field: "providerId", value: provider.providerId }],
266
+ update: {
267
+ domainVerified: true,
268
+ },
269
+ });
270
+
271
+ ctx.setStatus(204);
272
+ return;
273
+ },
274
+ );
275
+ };
package/src/routes/sso.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
2
- import type { Account, Session, User } from "better-auth";
2
+ import type { Account, Session, User, Verification } from "better-auth";
3
3
  import {
4
4
  createAuthorizationURL,
5
5
  generateState,
@@ -13,6 +13,7 @@ import {
13
13
  sessionMiddleware,
14
14
  } from "better-auth/api";
15
15
  import { setSessionCookie } from "better-auth/cookies";
16
+ import { generateRandomString } from "better-auth/crypto";
16
17
  import { handleOAuthUserInfo } from "better-auth/oauth2";
17
18
  import { decodeJwt } from "jose";
18
19
  import * as saml from "samlify";
@@ -21,6 +22,7 @@ import type { IdentityProvider } from "samlify/types/src/entity-idp";
21
22
  import type { FlowResult } from "samlify/types/src/flow";
22
23
  import * as z from "zod/v4";
23
24
  import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "../types";
25
+ import { validateEmailDomain } from "../utils";
24
26
 
25
27
  /**
26
28
  * Safely parses a value that might be a JSON string or already a parsed object
@@ -126,7 +128,7 @@ export const spMetadata = () => {
126
128
  );
127
129
  };
128
130
 
129
- export const registerSSOProvider = (options?: SSOOptions) => {
131
+ export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
130
132
  return createAuthEndpoint(
131
133
  "/sso/register",
132
134
  {
@@ -358,6 +360,16 @@ export const registerSSOProvider = (options?: SSOOptions) => {
358
360
  description:
359
361
  "The domain of the provider, used for email matching",
360
362
  },
363
+ domainVerified: {
364
+ type: "boolean",
365
+ description:
366
+ "A boolean indicating whether the domain has been verified or not",
367
+ },
368
+ domainVerificationToken: {
369
+ type: "string",
370
+ description:
371
+ "Domain verification token. It can be used to prove ownership over the SSO domain",
372
+ },
361
373
  oidcConfig: {
362
374
  type: "object",
363
375
  properties: {
@@ -586,12 +598,13 @@ export const registerSSOProvider = (options?: SSOOptions) => {
586
598
 
587
599
  const provider = await ctx.context.adapter.create<
588
600
  Record<string, any>,
589
- SSOProvider
601
+ SSOProvider<O>
590
602
  >({
591
603
  model: "ssoProvider",
592
604
  data: {
593
605
  issuer: body.issuer,
594
606
  domain: body.domain,
607
+ domainVerified: false,
595
608
  oidcConfig: body.oidcConfig
596
609
  ? JSON.stringify({
597
610
  issuer: body.issuer,
@@ -640,6 +653,34 @@ export const registerSSOProvider = (options?: SSOOptions) => {
640
653
  },
641
654
  });
642
655
 
656
+ let domainVerificationToken: string | undefined;
657
+ let domainVerified: boolean | undefined;
658
+
659
+ if (options?.domainVerification?.enabled) {
660
+ domainVerified = false;
661
+ domainVerificationToken = generateRandomString(24);
662
+
663
+ await ctx.context.adapter.create<Verification>({
664
+ model: "verification",
665
+ data: {
666
+ identifier: options.domainVerification?.tokenPrefix
667
+ ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}`
668
+ : `better-auth-token-${provider.providerId}`,
669
+ createdAt: new Date(),
670
+ updatedAt: new Date(),
671
+ value: domainVerificationToken,
672
+ expiresAt: new Date(Date.now() + 3600 * 24 * 7 * 1000), // 1 week
673
+ },
674
+ });
675
+ }
676
+
677
+ type SSOProviderReturn = O["domainVerification"] extends { enabled: true }
678
+ ? {
679
+ domainVerified: boolean;
680
+ domainVerificationToken: string;
681
+ } & SSOProvider<O>
682
+ : SSOProvider<O>;
683
+
643
684
  return ctx.json({
644
685
  ...provider,
645
686
  oidcConfig: JSON.parse(
@@ -649,7 +690,11 @@ export const registerSSOProvider = (options?: SSOOptions) => {
649
690
  provider.samlConfig as unknown as string,
650
691
  ) as SAMLConfig,
651
692
  redirectURI: `${ctx.context.baseURL}/sso/callback/${provider.providerId}`,
652
- });
693
+ ...(options?.domainVerification?.enabled ? { domainVerified } : {}),
694
+ ...(options?.domainVerification?.enabled
695
+ ? { domainVerificationToken }
696
+ : {}),
697
+ } as unknown as SSOProviderReturn);
653
698
  },
654
699
  );
655
700
  };
@@ -840,7 +885,7 @@ export const signInSSO = (options?: SSOOptions) => {
840
885
  return res.id;
841
886
  });
842
887
  }
843
- let provider: SSOProvider | null = null;
888
+ let provider: SSOProvider<SSOOptions> | null = null;
844
889
  if (options?.defaultSSO?.length) {
845
890
  // Find matching default SSO provider by providerId
846
891
  const matchingDefault = providerId
@@ -862,7 +907,10 @@ export const signInSSO = (options?: SSOOptions) => {
862
907
  oidcConfig: matchingDefault.oidcConfig,
863
908
  samlConfig: matchingDefault.samlConfig,
864
909
  domain: matchingDefault.domain,
865
- };
910
+ ...(options.domainVerification?.enabled
911
+ ? { domainVerified: true }
912
+ : {}),
913
+ } as SSOProvider<SSOOptions>;
866
914
  }
867
915
  }
868
916
  if (!providerId && !orgId && !domain) {
@@ -873,7 +921,7 @@ export const signInSSO = (options?: SSOOptions) => {
873
921
  // Try to find provider in database
874
922
  if (!provider) {
875
923
  provider = await ctx.context.adapter
876
- .findOne<SSOProvider>({
924
+ .findOne<SSOProvider<SSOOptions>>({
877
925
  model: "ssoProvider",
878
926
  where: [
879
927
  {
@@ -925,6 +973,15 @@ export const signInSSO = (options?: SSOOptions) => {
925
973
  }
926
974
  }
927
975
 
976
+ if (
977
+ options?.domainVerification?.enabled &&
978
+ !("domainVerified" in provider && provider.domainVerified)
979
+ ) {
980
+ throw new APIError("UNAUTHORIZED", {
981
+ message: "Provider domain has not been verified",
982
+ });
983
+ }
984
+
928
985
  if (provider.oidcConfig && body.providerType !== "saml") {
929
986
  let finalAuthUrl = provider.oidcConfig.authorizationEndpoint;
930
987
  if (!finalAuthUrl && provider.oidcConfig.discoveryEndpoint) {
@@ -1028,6 +1085,10 @@ export const callbackSSO = (options?: SSOOptions) => {
1028
1085
  error: z.string().optional(),
1029
1086
  error_description: z.string().optional(),
1030
1087
  }),
1088
+ allowedMediaTypes: [
1089
+ "application/x-www-form-urlencoded",
1090
+ "application/json",
1091
+ ],
1031
1092
  metadata: {
1032
1093
  isAction: false,
1033
1094
  openapi: {
@@ -1060,7 +1121,7 @@ export const callbackSSO = (options?: SSOOptions) => {
1060
1121
  }?error=${error}&error_description=${error_description}`,
1061
1122
  );
1062
1123
  }
1063
- let provider: SSOProvider | null = null;
1124
+ let provider: SSOProvider<SSOOptions> | null = null;
1064
1125
  if (options?.defaultSSO?.length) {
1065
1126
  const matchingDefault = options.defaultSSO.find(
1066
1127
  (defaultProvider) =>
@@ -1071,7 +1132,10 @@ export const callbackSSO = (options?: SSOOptions) => {
1071
1132
  ...matchingDefault,
1072
1133
  issuer: matchingDefault.oidcConfig?.issuer || "",
1073
1134
  userId: "default",
1074
- };
1135
+ ...(options.domainVerification?.enabled
1136
+ ? { domainVerified: true }
1137
+ : {}),
1138
+ } as SSOProvider<SSOOptions>;
1075
1139
  }
1076
1140
  }
1077
1141
  if (!provider) {
@@ -1095,7 +1159,7 @@ export const callbackSSO = (options?: SSOOptions) => {
1095
1159
  ...res,
1096
1160
  oidcConfig:
1097
1161
  safeJsonParse<OIDCConfig>(res.oidcConfig) || undefined,
1098
- } as SSOProvider;
1162
+ } as SSOProvider<SSOOptions>;
1099
1163
  });
1100
1164
  }
1101
1165
  if (!provider) {
@@ -1106,6 +1170,15 @@ export const callbackSSO = (options?: SSOOptions) => {
1106
1170
  );
1107
1171
  }
1108
1172
 
1173
+ if (
1174
+ options?.domainVerification?.enabled &&
1175
+ !("domainVerified" in provider && provider.domainVerified)
1176
+ ) {
1177
+ throw new APIError("UNAUTHORIZED", {
1178
+ message: "Provider domain has not been verified",
1179
+ });
1180
+ }
1181
+
1109
1182
  let config = provider.oidcConfig;
1110
1183
 
1111
1184
  if (!config) {
@@ -1401,7 +1474,7 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1401
1474
  async (ctx) => {
1402
1475
  const { SAMLResponse, RelayState } = ctx.body;
1403
1476
  const { providerId } = ctx.params;
1404
- let provider: SSOProvider | null = null;
1477
+ let provider: SSOProvider<SSOOptions> | null = null;
1405
1478
  if (options?.defaultSSO?.length) {
1406
1479
  const matchingDefault = options.defaultSSO.find(
1407
1480
  (defaultProvider) => defaultProvider.providerId === providerId,
@@ -1411,12 +1484,15 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1411
1484
  ...matchingDefault,
1412
1485
  userId: "default",
1413
1486
  issuer: matchingDefault.samlConfig?.issuer || "",
1414
- };
1487
+ ...(options.domainVerification?.enabled
1488
+ ? { domainVerified: true }
1489
+ : {}),
1490
+ } as SSOProvider<SSOOptions>;
1415
1491
  }
1416
1492
  }
1417
1493
  if (!provider) {
1418
1494
  provider = await ctx.context.adapter
1419
- .findOne<SSOProvider>({
1495
+ .findOne<SSOProvider<SSOOptions>>({
1420
1496
  model: "ssoProvider",
1421
1497
  where: [{ field: "providerId", value: providerId }],
1422
1498
  })
@@ -1439,6 +1515,15 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1439
1515
  });
1440
1516
  }
1441
1517
 
1518
+ if (
1519
+ options?.domainVerification?.enabled &&
1520
+ !("domainVerified" in provider && provider.domainVerified)
1521
+ ) {
1522
+ throw new APIError("UNAUTHORIZED", {
1523
+ message: "Provider domain has not been verified",
1524
+ });
1525
+ }
1526
+
1442
1527
  const parsedSamlConfig = safeJsonParse<SAMLConfig>(
1443
1528
  provider.samlConfig as unknown as string,
1444
1529
  );
@@ -1713,6 +1798,10 @@ export const acsEndpoint = (options?: SSOOptions) => {
1713
1798
  }),
1714
1799
  metadata: {
1715
1800
  isAction: false,
1801
+ allowedMediaTypes: [
1802
+ "application/x-www-form-urlencoded",
1803
+ "application/json",
1804
+ ],
1716
1805
  openapi: {
1717
1806
  operationId: "handleSAMLAssertionConsumerService",
1718
1807
  summary: "SAML Assertion Consumer Service",
@@ -1732,7 +1821,7 @@ export const acsEndpoint = (options?: SSOOptions) => {
1732
1821
  const { providerId } = ctx.params;
1733
1822
 
1734
1823
  // If defaultSSO is configured, use it as the provider
1735
- let provider: SSOProvider | null = null;
1824
+ let provider: SSOProvider<SSOOptions> | null = null;
1736
1825
 
1737
1826
  if (options?.defaultSSO?.length) {
1738
1827
  // For ACS endpoint, we can use the first default provider or try to match by providerId
@@ -1749,11 +1838,14 @@ export const acsEndpoint = (options?: SSOOptions) => {
1749
1838
  userId: "default",
1750
1839
  samlConfig: matchingDefault.samlConfig,
1751
1840
  domain: matchingDefault.domain,
1841
+ ...(options.domainVerification?.enabled
1842
+ ? { domainVerified: true }
1843
+ : {}),
1752
1844
  };
1753
1845
  }
1754
1846
  } else {
1755
1847
  provider = await ctx.context.adapter
1756
- .findOne<SSOProvider>({
1848
+ .findOne<SSOProvider<SSOOptions>>({
1757
1849
  model: "ssoProvider",
1758
1850
  where: [
1759
1851
  {
@@ -1781,6 +1873,15 @@ export const acsEndpoint = (options?: SSOOptions) => {
1781
1873
  });
1782
1874
  }
1783
1875
 
1876
+ if (
1877
+ options?.domainVerification?.enabled &&
1878
+ !("domainVerified" in provider && provider.domainVerified)
1879
+ ) {
1880
+ throw new APIError("UNAUTHORIZED", {
1881
+ message: "Provider domain has not been verified",
1882
+ });
1883
+ }
1884
+
1784
1885
  const parsedSamlConfig = provider.samlConfig;
1785
1886
  // Configure SP and IdP
1786
1887
  const sp = saml.ServiceProvider({
@@ -1954,7 +2055,10 @@ export const acsEndpoint = (options?: SSOOptions) => {
1954
2055
  const isTrustedProvider =
1955
2056
  ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
1956
2057
  provider.providerId,
1957
- );
2058
+ ) ||
2059
+ ("domainVerified" in provider &&
2060
+ provider.domainVerified &&
2061
+ validateEmailDomain(userInfo.email, provider.domain));
1958
2062
  if (!isTrustedProvider) {
1959
2063
  throw ctx.redirect(
1960
2064
  `${parsedSamlConfig.callbackUrl}?error=account_not_found`,
package/src/types.ts CHANGED
@@ -81,7 +81,7 @@ export interface SAMLConfig {
81
81
  mapping?: SAMLMapping | undefined;
82
82
  }
83
83
 
84
- export type SSOProvider = {
84
+ type BaseSSOProvider = {
85
85
  issuer: string;
86
86
  oidcConfig?: OIDCConfig | undefined;
87
87
  samlConfig?: SAMLConfig | undefined;
@@ -91,6 +91,13 @@ export type SSOProvider = {
91
91
  domain: string;
92
92
  };
93
93
 
94
+ export type SSOProvider<O extends SSOOptions> =
95
+ O["domainVerification"] extends { enabled: true }
96
+ ? {
97
+ domainVerified: boolean;
98
+ } & BaseSSOProvider
99
+ : BaseSSOProvider;
100
+
94
101
  export interface SSOOptions {
95
102
  /**
96
103
  * custom function to provision a user when they sign in with an SSO provider.
@@ -112,7 +119,7 @@ export interface SSOOptions {
112
119
  /**
113
120
  * The SSO provider
114
121
  */
115
- provider: SSOProvider;
122
+ provider: SSOProvider<SSOOptions>;
116
123
  }) => Promise<void>)
117
124
  | undefined;
118
125
  /**
@@ -138,7 +145,7 @@ export interface SSOOptions {
138
145
  /**
139
146
  * The SSO provider
140
147
  */
141
- provider: SSOProvider;
148
+ provider: SSOProvider<SSOOptions>;
142
149
  }) => Promise<"member" | "admin">;
143
150
  }
144
151
  | undefined;
@@ -228,4 +235,22 @@ export interface SSOOptions {
228
235
  * @default false
229
236
  */
230
237
  trustEmailVerified?: boolean | undefined;
238
+ /**
239
+ * Enable domain verification on SSO providers
240
+ *
241
+ * When this option is enabled, new SSO providers will require the associated domain to be verified by the owner
242
+ * prior to allowing sign-ins.
243
+ */
244
+ domainVerification?: {
245
+ /**
246
+ * Enables or disables the domain verification feature
247
+ */
248
+ enabled?: boolean;
249
+ /**
250
+ * Prefix used to generate the domain verification token
251
+ *
252
+ * @default "better-auth-token-"
253
+ */
254
+ tokenPrefix?: string;
255
+ };
231
256
  }
package/src/utils.ts ADDED
@@ -0,0 +1,10 @@
1
+ export const validateEmailDomain = (email: string, domain: string) => {
2
+ const emailDomain = email.split("@")[1]?.toLowerCase();
3
+ const providerDomain = domain.toLowerCase();
4
+ if (!emailDomain || !providerDomain) {
5
+ return false;
6
+ }
7
+ return (
8
+ emailDomain === providerDomain || emailDomain.endsWith(`.${providerDomain}`)
9
+ );
10
+ };