@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.
- package/.turbo/turbo-build.log +8 -8
- package/dist/client.d.mts +14 -3
- package/dist/client.mjs +1 -1
- package/dist/{index-DOws6HlV.d.mts → index-BdGHTkZi.d.mts} +173 -19
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +253 -17
- package/package.json +4 -4
- package/src/client.ts +20 -3
- package/src/domain-verification.test.ts +550 -0
- package/src/index.ts +57 -11
- package/src/routes/domain-verification.ts +275 -0
- package/src/routes/sso.ts +120 -16
- package/src/types.ts +28 -3
- package/src/utils.ts +10 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
+
};
|