@better-auth/sso 1.5.0-beta.18 → 1.5.0-beta.19
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/README.md +17 -0
- package/package.json +22 -19
- package/.turbo/turbo-build.log +0 -20
- package/src/client.ts +0 -29
- package/src/constants.ts +0 -79
- package/src/domain-verification.test.ts +0 -592
- package/src/index.ts +0 -306
- package/src/linking/index.ts +0 -2
- package/src/linking/org-assignment.test.ts +0 -325
- package/src/linking/org-assignment.ts +0 -176
- package/src/linking/types.ts +0 -10
- package/src/oidc/discovery.test.ts +0 -1152
- package/src/oidc/discovery.ts +0 -494
- package/src/oidc/errors.ts +0 -92
- package/src/oidc/index.ts +0 -31
- package/src/oidc/types.ts +0 -219
- package/src/oidc.test.ts +0 -987
- package/src/providers.test.ts +0 -1320
- package/src/routes/domain-verification.ts +0 -297
- package/src/routes/helpers.ts +0 -126
- package/src/routes/providers.ts +0 -567
- package/src/routes/schemas.ts +0 -96
- package/src/routes/sso.ts +0 -3361
- package/src/saml/algorithms.test.ts +0 -441
- package/src/saml/algorithms.ts +0 -338
- package/src/saml/assertions.test.ts +0 -239
- package/src/saml/assertions.ts +0 -62
- package/src/saml/error-codes.ts +0 -11
- package/src/saml/index.ts +0 -13
- package/src/saml/parser.ts +0 -56
- package/src/saml-state.ts +0 -78
- package/src/saml.test.ts +0 -5140
- package/src/types.ts +0 -416
- package/src/utils.test.ts +0 -106
- package/src/utils.ts +0 -81
- package/tsconfig.json +0 -14
- package/tsdown.config.ts +0 -8
- package/vitest.config.ts +0 -8
|
@@ -1,297 +0,0 @@
|
|
|
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
|
-
const DNS_LABEL_MAX_LENGTH = 63;
|
|
12
|
-
const DEFAULT_TOKEN_PREFIX = "better-auth-token";
|
|
13
|
-
|
|
14
|
-
const domainVerificationBodySchema = z.object({
|
|
15
|
-
providerId: z.string(),
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
export function getVerificationIdentifier(
|
|
19
|
-
options: SSOOptions,
|
|
20
|
-
providerId: string,
|
|
21
|
-
): string {
|
|
22
|
-
const tokenPrefix =
|
|
23
|
-
options.domainVerification?.tokenPrefix || DEFAULT_TOKEN_PREFIX;
|
|
24
|
-
return `_${tokenPrefix}-${providerId}`;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export const requestDomainVerification = (options: SSOOptions) => {
|
|
28
|
-
return createAuthEndpoint(
|
|
29
|
-
"/sso/request-domain-verification",
|
|
30
|
-
{
|
|
31
|
-
method: "POST",
|
|
32
|
-
body: domainVerificationBodySchema,
|
|
33
|
-
metadata: {
|
|
34
|
-
openapi: {
|
|
35
|
-
summary: "Request a domain verification",
|
|
36
|
-
description:
|
|
37
|
-
"Request a domain verification for the given SSO provider",
|
|
38
|
-
responses: {
|
|
39
|
-
"404": {
|
|
40
|
-
description: "Provider not found",
|
|
41
|
-
},
|
|
42
|
-
"409": {
|
|
43
|
-
description: "Domain has already been verified",
|
|
44
|
-
},
|
|
45
|
-
"201": {
|
|
46
|
-
description: "Domain submitted for verification",
|
|
47
|
-
},
|
|
48
|
-
},
|
|
49
|
-
},
|
|
50
|
-
},
|
|
51
|
-
use: [sessionMiddleware],
|
|
52
|
-
},
|
|
53
|
-
async (ctx) => {
|
|
54
|
-
const body = ctx.body;
|
|
55
|
-
const provider = await ctx.context.adapter.findOne<
|
|
56
|
-
SSOProvider<SSOOptions>
|
|
57
|
-
>({
|
|
58
|
-
model: "ssoProvider",
|
|
59
|
-
where: [{ field: "providerId", value: body.providerId }],
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
if (!provider) {
|
|
63
|
-
throw new APIError("NOT_FOUND", {
|
|
64
|
-
message: "Provider not found",
|
|
65
|
-
code: "PROVIDER_NOT_FOUND",
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const userId = ctx.context.session.user.id;
|
|
70
|
-
let isOrgMember = true;
|
|
71
|
-
if (provider.organizationId) {
|
|
72
|
-
const membershipsCount = await ctx.context.adapter.count({
|
|
73
|
-
model: "member",
|
|
74
|
-
where: [
|
|
75
|
-
{ field: "userId", value: userId },
|
|
76
|
-
{ field: "organizationId", value: provider.organizationId },
|
|
77
|
-
],
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
isOrgMember = membershipsCount > 0;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (provider.userId !== userId || !isOrgMember) {
|
|
84
|
-
throw new APIError("FORBIDDEN", {
|
|
85
|
-
message:
|
|
86
|
-
"User must be owner of or belong to the SSO provider organization",
|
|
87
|
-
code: "INSUFICCIENT_ACCESS",
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
if ("domainVerified" in provider && provider.domainVerified) {
|
|
92
|
-
throw new APIError("CONFLICT", {
|
|
93
|
-
message: "Domain has already been verified",
|
|
94
|
-
code: "DOMAIN_VERIFIED",
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const identifier = getVerificationIdentifier(
|
|
99
|
-
options,
|
|
100
|
-
provider.providerId,
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
const activeVerification =
|
|
104
|
-
await ctx.context.adapter.findOne<Verification>({
|
|
105
|
-
model: "verification",
|
|
106
|
-
where: [
|
|
107
|
-
{
|
|
108
|
-
field: "identifier",
|
|
109
|
-
value: identifier,
|
|
110
|
-
},
|
|
111
|
-
{ field: "expiresAt", value: new Date(), operator: "gt" },
|
|
112
|
-
],
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
if (activeVerification) {
|
|
116
|
-
ctx.setStatus(201);
|
|
117
|
-
return ctx.json({ domainVerificationToken: activeVerification.value });
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const domainVerificationToken = generateRandomString(24);
|
|
121
|
-
await ctx.context.adapter.create<Verification>({
|
|
122
|
-
model: "verification",
|
|
123
|
-
data: {
|
|
124
|
-
identifier,
|
|
125
|
-
createdAt: new Date(),
|
|
126
|
-
updatedAt: new Date(),
|
|
127
|
-
value: domainVerificationToken,
|
|
128
|
-
expiresAt: new Date(Date.now() + 3600 * 24 * 7 * 1000), // 1 week
|
|
129
|
-
},
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
ctx.setStatus(201);
|
|
133
|
-
return ctx.json({
|
|
134
|
-
domainVerificationToken,
|
|
135
|
-
});
|
|
136
|
-
},
|
|
137
|
-
);
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
export const verifyDomain = (options: SSOOptions) => {
|
|
141
|
-
return createAuthEndpoint(
|
|
142
|
-
"/sso/verify-domain",
|
|
143
|
-
{
|
|
144
|
-
method: "POST",
|
|
145
|
-
body: domainVerificationBodySchema,
|
|
146
|
-
metadata: {
|
|
147
|
-
openapi: {
|
|
148
|
-
summary: "Verify the provider domain ownership",
|
|
149
|
-
description: "Verify the provider domain ownership via DNS records",
|
|
150
|
-
responses: {
|
|
151
|
-
"404": {
|
|
152
|
-
description: "Provider not found",
|
|
153
|
-
},
|
|
154
|
-
"409": {
|
|
155
|
-
description:
|
|
156
|
-
"Domain has already been verified or no pending verification exists",
|
|
157
|
-
},
|
|
158
|
-
"502": {
|
|
159
|
-
description:
|
|
160
|
-
"Unable to verify domain ownership due to upstream validator error",
|
|
161
|
-
},
|
|
162
|
-
"204": {
|
|
163
|
-
description: "Domain ownership was verified",
|
|
164
|
-
},
|
|
165
|
-
},
|
|
166
|
-
},
|
|
167
|
-
},
|
|
168
|
-
use: [sessionMiddleware],
|
|
169
|
-
},
|
|
170
|
-
async (ctx) => {
|
|
171
|
-
const body = ctx.body;
|
|
172
|
-
const provider = await ctx.context.adapter.findOne<
|
|
173
|
-
SSOProvider<SSOOptions>
|
|
174
|
-
>({
|
|
175
|
-
model: "ssoProvider",
|
|
176
|
-
where: [{ field: "providerId", value: body.providerId }],
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
if (!provider) {
|
|
180
|
-
throw new APIError("NOT_FOUND", {
|
|
181
|
-
message: "Provider not found",
|
|
182
|
-
code: "PROVIDER_NOT_FOUND",
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const userId = ctx.context.session.user.id;
|
|
187
|
-
let isOrgMember = true;
|
|
188
|
-
if (provider.organizationId) {
|
|
189
|
-
const membershipsCount = await ctx.context.adapter.count({
|
|
190
|
-
model: "member",
|
|
191
|
-
where: [
|
|
192
|
-
{ field: "userId", value: userId },
|
|
193
|
-
{ field: "organizationId", value: provider.organizationId },
|
|
194
|
-
],
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
isOrgMember = membershipsCount > 0;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (provider.userId !== userId || !isOrgMember) {
|
|
201
|
-
throw new APIError("FORBIDDEN", {
|
|
202
|
-
message:
|
|
203
|
-
"User must be owner of or belong to the SSO provider organization",
|
|
204
|
-
code: "INSUFICCIENT_ACCESS",
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
if ("domainVerified" in provider && provider.domainVerified) {
|
|
209
|
-
throw new APIError("CONFLICT", {
|
|
210
|
-
message: "Domain has already been verified",
|
|
211
|
-
code: "DOMAIN_VERIFIED",
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const identifier = getVerificationIdentifier(
|
|
216
|
-
options,
|
|
217
|
-
provider.providerId,
|
|
218
|
-
);
|
|
219
|
-
|
|
220
|
-
if (identifier.length > DNS_LABEL_MAX_LENGTH) {
|
|
221
|
-
throw new APIError("BAD_REQUEST", {
|
|
222
|
-
message: `Verification identifier exceeds the DNS label limit of ${DNS_LABEL_MAX_LENGTH} characters`,
|
|
223
|
-
code: "IDENTIFIER_TOO_LONG",
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const activeVerification =
|
|
228
|
-
await ctx.context.adapter.findOne<Verification>({
|
|
229
|
-
model: "verification",
|
|
230
|
-
where: [
|
|
231
|
-
{
|
|
232
|
-
field: "identifier",
|
|
233
|
-
value: identifier,
|
|
234
|
-
},
|
|
235
|
-
{ field: "expiresAt", value: new Date(), operator: "gt" },
|
|
236
|
-
],
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
if (!activeVerification) {
|
|
240
|
-
throw new APIError("NOT_FOUND", {
|
|
241
|
-
message: "No pending domain verification exists",
|
|
242
|
-
code: "NO_PENDING_VERIFICATION",
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
let records: string[] = [];
|
|
247
|
-
let dns: typeof import("node:dns/promises");
|
|
248
|
-
|
|
249
|
-
try {
|
|
250
|
-
dns = await import("node:dns/promises");
|
|
251
|
-
} catch (error) {
|
|
252
|
-
ctx.context.logger.error(
|
|
253
|
-
"The core node:dns module is required for the domain verification feature",
|
|
254
|
-
error,
|
|
255
|
-
);
|
|
256
|
-
throw new APIError("INTERNAL_SERVER_ERROR", {
|
|
257
|
-
message: "Unable to verify domain ownership due to server error",
|
|
258
|
-
code: "DOMAIN_VERIFICATION_FAILED",
|
|
259
|
-
});
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
try {
|
|
263
|
-
const hostname = new URL(provider.domain).hostname;
|
|
264
|
-
const dnsRecords = await dns.resolveTxt(`${identifier}.${hostname}`);
|
|
265
|
-
records = dnsRecords.flat();
|
|
266
|
-
} catch (error) {
|
|
267
|
-
ctx.context.logger.warn(
|
|
268
|
-
"DNS resolution failure while validating domain ownership",
|
|
269
|
-
error,
|
|
270
|
-
);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
const record = records.find((record) =>
|
|
274
|
-
record.includes(
|
|
275
|
-
`${activeVerification.identifier}=${activeVerification.value}`,
|
|
276
|
-
),
|
|
277
|
-
);
|
|
278
|
-
if (!record) {
|
|
279
|
-
throw new APIError("BAD_GATEWAY", {
|
|
280
|
-
message: "Unable to verify domain ownership. Try again later",
|
|
281
|
-
code: "DOMAIN_VERIFICATION_FAILED",
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
await ctx.context.adapter.update<SSOProvider<SSOOptions>>({
|
|
286
|
-
model: "ssoProvider",
|
|
287
|
-
where: [{ field: "providerId", value: provider.providerId }],
|
|
288
|
-
update: {
|
|
289
|
-
domainVerified: true,
|
|
290
|
-
},
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
ctx.setStatus(204);
|
|
294
|
-
return;
|
|
295
|
-
},
|
|
296
|
-
);
|
|
297
|
-
};
|
package/src/routes/helpers.ts
DELETED
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
import type { DBAdapter } from "@better-auth/core/db/adapter";
|
|
2
|
-
import saml from "samlify";
|
|
3
|
-
import type { SAMLConfig, SSOOptions, SSOProvider } from "../types";
|
|
4
|
-
import { safeJsonParse } from "../utils";
|
|
5
|
-
|
|
6
|
-
export async function findSAMLProvider(
|
|
7
|
-
providerId: string,
|
|
8
|
-
options: SSOOptions | undefined,
|
|
9
|
-
adapter: DBAdapter,
|
|
10
|
-
): Promise<SSOProvider<SSOOptions> | null> {
|
|
11
|
-
if (options?.defaultSSO?.length) {
|
|
12
|
-
const match = options.defaultSSO.find((p) => p.providerId === providerId);
|
|
13
|
-
if (match) {
|
|
14
|
-
return {
|
|
15
|
-
...match,
|
|
16
|
-
userId: "default",
|
|
17
|
-
issuer: match.samlConfig?.issuer || "",
|
|
18
|
-
...(options.domainVerification?.enabled
|
|
19
|
-
? { domainVerified: true }
|
|
20
|
-
: {}),
|
|
21
|
-
} as SSOProvider<SSOOptions>;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const res = await adapter.findOne<SSOProvider<SSOOptions>>({
|
|
26
|
-
model: "ssoProvider",
|
|
27
|
-
where: [{ field: "providerId", value: providerId }],
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
if (!res) return null;
|
|
31
|
-
|
|
32
|
-
return {
|
|
33
|
-
...res,
|
|
34
|
-
samlConfig: res.samlConfig
|
|
35
|
-
? safeJsonParse<SAMLConfig>(res.samlConfig as unknown as string) ||
|
|
36
|
-
undefined
|
|
37
|
-
: undefined,
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function createSP(
|
|
42
|
-
config: SAMLConfig,
|
|
43
|
-
baseURL: string,
|
|
44
|
-
providerId: string,
|
|
45
|
-
sloOptions?: {
|
|
46
|
-
wantLogoutRequestSigned?: boolean;
|
|
47
|
-
wantLogoutResponseSigned?: boolean;
|
|
48
|
-
},
|
|
49
|
-
) {
|
|
50
|
-
const sloLocation = `${baseURL}/sso/saml2/sp/slo/${providerId}`;
|
|
51
|
-
return saml.ServiceProvider({
|
|
52
|
-
entityID: config.spMetadata?.entityID || config.issuer,
|
|
53
|
-
assertionConsumerService: [
|
|
54
|
-
{
|
|
55
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
56
|
-
Location:
|
|
57
|
-
config.callbackUrl || `${baseURL}/sso/saml2/sp/acs/${providerId}`,
|
|
58
|
-
},
|
|
59
|
-
],
|
|
60
|
-
singleLogoutService: [
|
|
61
|
-
{
|
|
62
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
63
|
-
Location: sloLocation,
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
67
|
-
Location: sloLocation,
|
|
68
|
-
},
|
|
69
|
-
],
|
|
70
|
-
wantMessageSigned: config.wantAssertionsSigned || false,
|
|
71
|
-
wantLogoutRequestSigned: sloOptions?.wantLogoutRequestSigned ?? false,
|
|
72
|
-
wantLogoutResponseSigned: sloOptions?.wantLogoutResponseSigned ?? false,
|
|
73
|
-
metadata: config.spMetadata?.metadata,
|
|
74
|
-
privateKey: config.spMetadata?.privateKey || config.privateKey,
|
|
75
|
-
privateKeyPass: config.spMetadata?.privateKeyPass,
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function createIdP(config: SAMLConfig) {
|
|
80
|
-
const idpData = config.idpMetadata;
|
|
81
|
-
if (idpData?.metadata) {
|
|
82
|
-
return saml.IdentityProvider({
|
|
83
|
-
metadata: idpData.metadata,
|
|
84
|
-
privateKey: idpData.privateKey,
|
|
85
|
-
privateKeyPass: idpData.privateKeyPass,
|
|
86
|
-
encPrivateKey: idpData.encPrivateKey,
|
|
87
|
-
encPrivateKeyPass: idpData.encPrivateKeyPass,
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
return saml.IdentityProvider({
|
|
91
|
-
entityID: idpData?.entityID || config.issuer,
|
|
92
|
-
singleSignOnService: idpData?.singleSignOnService || [
|
|
93
|
-
{
|
|
94
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
95
|
-
Location: config.entryPoint,
|
|
96
|
-
},
|
|
97
|
-
],
|
|
98
|
-
singleLogoutService: idpData?.singleLogoutService,
|
|
99
|
-
signingCert: idpData?.cert || config.cert,
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function escapeHtml(str: string | undefined | null): string {
|
|
104
|
-
if (!str) return "";
|
|
105
|
-
return String(str)
|
|
106
|
-
.replace(/&/g, "&")
|
|
107
|
-
.replace(/</g, "<")
|
|
108
|
-
.replace(/>/g, ">")
|
|
109
|
-
.replace(/"/g, """)
|
|
110
|
-
.replace(/'/g, "'");
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export function createSAMLPostForm(
|
|
114
|
-
action: string,
|
|
115
|
-
samlParam: string,
|
|
116
|
-
samlValue: string,
|
|
117
|
-
relayState?: string,
|
|
118
|
-
): Response {
|
|
119
|
-
const safeAction = escapeHtml(action);
|
|
120
|
-
const safeSamlParam = escapeHtml(samlParam);
|
|
121
|
-
const safeSamlValue = escapeHtml(samlValue);
|
|
122
|
-
const safeRelayState = relayState ? escapeHtml(relayState) : undefined;
|
|
123
|
-
|
|
124
|
-
const html = `<!DOCTYPE html><html><body onload="document.forms[0].submit();"><form method="POST" action="${safeAction}"><input type="hidden" name="${safeSamlParam}" value="${safeSamlValue}" />${safeRelayState ? `<input type="hidden" name="RelayState" value="${safeRelayState}" />` : ""}<noscript><input type="submit" value="Continue" /></noscript></form></body></html>`;
|
|
125
|
-
return new Response(html, { headers: { "Content-Type": "text/html" } });
|
|
126
|
-
}
|