@better-auth/sso 1.4.0-beta.9 → 1.4.0

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/src/oidc.test.ts CHANGED
@@ -1,10 +1,10 @@
1
- import { afterAll, beforeAll, describe, expect, it } from "vitest";
2
- import { getTestInstanceMemory as getTestInstance } from "better-auth/test";
3
- import { sso } from ".";
4
- import { OAuth2Server } from "oauth2-mock-server";
5
1
  import { betterFetch } from "@better-fetch/fetch";
6
- import { organization } from "better-auth/plugins";
7
2
  import { createAuthClient } from "better-auth/client";
3
+ import { organization } from "better-auth/plugins";
4
+ import { getTestInstance } from "better-auth/test";
5
+ import { OAuth2Server } from "oauth2-mock-server";
6
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
7
+ import { sso } from ".";
8
8
  import { ssoClient } from "./client";
9
9
 
10
10
  let server = new OAuth2Server();
@@ -208,6 +208,7 @@ describe("SSO", async () => {
208
208
  expect(res.url).toContain(
209
209
  "redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fsso%2Fcallback%2Ftest",
210
210
  );
211
+ expect(res.url).toContain("login_hint=my-email%40localhost.com");
211
212
  const { callbackURL } = await simulateOAuthFlow(res.url, headers);
212
213
  expect(callbackURL).toContain("/dashboard");
213
214
  });
@@ -235,6 +236,7 @@ describe("SSO", async () => {
235
236
  const headers = new Headers();
236
237
  const res = await authClient.signIn.sso({
237
238
  providerId: "test",
239
+ loginHint: "user@example.com",
238
240
  callbackURL: "/dashboard",
239
241
  fetchOptions: {
240
242
  throw: true,
@@ -245,6 +247,7 @@ describe("SSO", async () => {
245
247
  expect(res.url).toContain(
246
248
  "redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fsso%2Fcallback%2Ftest",
247
249
  );
250
+ expect(res.url).toContain("login_hint=user%40example.com");
248
251
 
249
252
  const { callbackURL } = await simulateOAuthFlow(res.url, headers);
250
253
  expect(callbackURL).toContain("/dashboard");
@@ -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
+ };