@better-auth/sso 1.4.7-beta.4 → 1.4.8-beta.1
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 +7 -7
- package/dist/client.d.mts +1 -1
- package/dist/{index-GoyGoP_a.d.mts → index-DNWhGQW-.d.mts} +94 -77
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +537 -286
- package/package.json +3 -3
- package/src/constants.ts +42 -0
- package/src/domain-verification.test.ts +1 -0
- package/src/index.ts +38 -11
- package/src/linking/index.ts +2 -0
- package/src/linking/org-assignment.ts +158 -0
- package/src/linking/types.ts +10 -0
- package/src/oidc/discovery.test.ts +359 -25
- package/src/oidc/discovery.ts +168 -29
- package/src/oidc/errors.ts +6 -0
- package/src/oidc/types.ts +9 -0
- package/src/oidc.test.ts +3 -0
- package/src/routes/sso.ts +339 -332
- package/src/saml/algorithms.test.ts +205 -0
- package/src/saml/algorithms.ts +259 -0
- package/src/saml/index.ts +9 -0
- package/src/saml.test.ts +351 -127
- package/src/types.ts +18 -16
- package/src/authn-request-store.ts +0 -76
- package/src/authn-request.test.ts +0 -99
package/dist/index.mjs
CHANGED
|
@@ -1,56 +1,102 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { APIError, createAuthEndpoint, createAuthMiddleware, sessionMiddleware } from "better-auth/api";
|
|
2
|
+
import { XMLParser, XMLValidator } from "fast-xml-parser";
|
|
2
3
|
import * as saml from "samlify";
|
|
3
|
-
import { APIError, createAuthEndpoint, sessionMiddleware } from "better-auth/api";
|
|
4
4
|
import { generateRandomString } from "better-auth/crypto";
|
|
5
|
-
import * as z from "zod/v4";
|
|
5
|
+
import * as z$1 from "zod/v4";
|
|
6
|
+
import z from "zod/v4";
|
|
6
7
|
import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
|
|
7
8
|
import { HIDE_METADATA, createAuthorizationURL, generateState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
|
|
8
9
|
import { setSessionCookie } from "better-auth/cookies";
|
|
9
10
|
import { handleOAuthUserInfo } from "better-auth/oauth2";
|
|
10
11
|
import { decodeJwt } from "jose";
|
|
11
12
|
|
|
12
|
-
//#region src/
|
|
13
|
+
//#region src/linking/org-assignment.ts
|
|
13
14
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
15
|
+
* Assigns a user to an organization based on the SSO provider's organizationId.
|
|
16
|
+
* Used in SSO flows (OIDC, SAML) where the provider is already linked to an org.
|
|
16
17
|
*/
|
|
17
|
-
|
|
18
|
+
async function assignOrganizationFromProvider(ctx, options) {
|
|
19
|
+
const { user, profile, provider, token, provisioningOptions } = options;
|
|
20
|
+
if (!provider.organizationId) return;
|
|
21
|
+
if (provisioningOptions?.disabled) return;
|
|
22
|
+
if (!ctx.context.options.plugins?.find((plugin) => plugin.id === "organization")) return;
|
|
23
|
+
if (await ctx.context.adapter.findOne({
|
|
24
|
+
model: "member",
|
|
25
|
+
where: [{
|
|
26
|
+
field: "organizationId",
|
|
27
|
+
value: provider.organizationId
|
|
28
|
+
}, {
|
|
29
|
+
field: "userId",
|
|
30
|
+
value: user.id
|
|
31
|
+
}]
|
|
32
|
+
})) return;
|
|
33
|
+
const role = provisioningOptions?.getRole ? await provisioningOptions.getRole({
|
|
34
|
+
user,
|
|
35
|
+
userInfo: profile.rawAttributes || {},
|
|
36
|
+
token,
|
|
37
|
+
provider
|
|
38
|
+
}) : provisioningOptions?.defaultRole || "member";
|
|
39
|
+
await ctx.context.adapter.create({
|
|
40
|
+
model: "member",
|
|
41
|
+
data: {
|
|
42
|
+
organizationId: provider.organizationId,
|
|
43
|
+
userId: user.id,
|
|
44
|
+
role,
|
|
45
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
18
49
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
50
|
+
* Assigns a user to an organization based on their email domain.
|
|
51
|
+
* Looks up SSO providers that match the user's email domain and assigns
|
|
52
|
+
* the user to the associated organization.
|
|
53
|
+
*
|
|
54
|
+
* This enables domain-based org assignment for non-SSO sign-in methods
|
|
55
|
+
* (e.g., Google OAuth with @acme.com email gets added to Acme's org).
|
|
23
56
|
*/
|
|
24
|
-
function
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
57
|
+
async function assignOrganizationByDomain(ctx, options) {
|
|
58
|
+
const { user, provisioningOptions } = options;
|
|
59
|
+
if (provisioningOptions?.disabled) return;
|
|
60
|
+
if (!ctx.context.options.plugins?.find((plugin) => plugin.id === "organization")) return;
|
|
61
|
+
const domain = user.email.split("@")[1];
|
|
62
|
+
if (!domain) return;
|
|
63
|
+
const ssoProvider = await ctx.context.adapter.findOne({
|
|
64
|
+
model: "ssoProvider",
|
|
65
|
+
where: [{
|
|
66
|
+
field: "domain",
|
|
67
|
+
value: domain
|
|
68
|
+
}]
|
|
69
|
+
});
|
|
70
|
+
if (!ssoProvider || !ssoProvider.organizationId) return;
|
|
71
|
+
if (await ctx.context.adapter.findOne({
|
|
72
|
+
model: "member",
|
|
73
|
+
where: [{
|
|
74
|
+
field: "organizationId",
|
|
75
|
+
value: ssoProvider.organizationId
|
|
76
|
+
}, {
|
|
77
|
+
field: "userId",
|
|
78
|
+
value: user.id
|
|
79
|
+
}]
|
|
80
|
+
})) return;
|
|
81
|
+
const role = provisioningOptions?.getRole ? await provisioningOptions.getRole({
|
|
82
|
+
user,
|
|
83
|
+
userInfo: {},
|
|
84
|
+
provider: ssoProvider
|
|
85
|
+
}) : provisioningOptions?.defaultRole || "member";
|
|
86
|
+
await ctx.context.adapter.create({
|
|
87
|
+
model: "member",
|
|
88
|
+
data: {
|
|
89
|
+
organizationId: ssoProvider.organizationId,
|
|
90
|
+
userId: user.id,
|
|
91
|
+
role,
|
|
92
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
47
93
|
}
|
|
48
|
-
};
|
|
94
|
+
});
|
|
49
95
|
}
|
|
50
96
|
|
|
51
97
|
//#endregion
|
|
52
98
|
//#region src/routes/domain-verification.ts
|
|
53
|
-
const domainVerificationBodySchema = z.object({ providerId: z.string() });
|
|
99
|
+
const domainVerificationBodySchema = z$1.object({ providerId: z$1.string() });
|
|
54
100
|
const requestDomainVerification = (options) => {
|
|
55
101
|
return createAuthEndpoint("/sso/request-domain-verification", {
|
|
56
102
|
method: "POST",
|
|
@@ -223,6 +269,38 @@ const verifyDomain = (options) => {
|
|
|
223
269
|
});
|
|
224
270
|
};
|
|
225
271
|
|
|
272
|
+
//#endregion
|
|
273
|
+
//#region src/constants.ts
|
|
274
|
+
/**
|
|
275
|
+
* SAML Constants
|
|
276
|
+
*
|
|
277
|
+
* Centralized constants for SAML SSO functionality.
|
|
278
|
+
*/
|
|
279
|
+
/** Prefix for AuthnRequest IDs used in InResponseTo validation */
|
|
280
|
+
const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
|
|
281
|
+
/** Prefix for used Assertion IDs used in replay protection */
|
|
282
|
+
const USED_ASSERTION_KEY_PREFIX = "saml-used-assertion:";
|
|
283
|
+
/**
|
|
284
|
+
* Default TTL for AuthnRequest records (5 minutes).
|
|
285
|
+
* This should be sufficient for most IdPs while protecting against stale requests.
|
|
286
|
+
*/
|
|
287
|
+
const DEFAULT_AUTHN_REQUEST_TTL_MS = 300 * 1e3;
|
|
288
|
+
/**
|
|
289
|
+
* Default TTL for used assertion records (15 minutes).
|
|
290
|
+
* This should match the maximum expected NotOnOrAfter window plus clock skew.
|
|
291
|
+
*/
|
|
292
|
+
const DEFAULT_ASSERTION_TTL_MS = 900 * 1e3;
|
|
293
|
+
/**
|
|
294
|
+
* Default clock skew tolerance (5 minutes).
|
|
295
|
+
* Allows for minor time differences between IdP and SP servers.
|
|
296
|
+
*
|
|
297
|
+
* Accommodates:
|
|
298
|
+
* - Network latency and processing time
|
|
299
|
+
* - Clock synchronization differences (NTP drift)
|
|
300
|
+
* - Distributed systems across timezones
|
|
301
|
+
*/
|
|
302
|
+
const DEFAULT_CLOCK_SKEW_MS = 300 * 1e3;
|
|
303
|
+
|
|
226
304
|
//#endregion
|
|
227
305
|
//#region src/oidc/types.ts
|
|
228
306
|
/**
|
|
@@ -268,24 +346,25 @@ const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
|
|
|
268
346
|
*
|
|
269
347
|
* This function:
|
|
270
348
|
* 1. Computes the discovery URL from the issuer
|
|
271
|
-
* 2. Validates the discovery URL
|
|
349
|
+
* 2. Validates the discovery URL
|
|
272
350
|
* 3. Fetches the discovery document
|
|
273
351
|
* 4. Validates the discovery document (issuer match + required fields)
|
|
274
|
-
* 5. Normalizes URLs
|
|
352
|
+
* 5. Normalizes URLs
|
|
275
353
|
* 6. Selects token endpoint auth method
|
|
276
354
|
* 7. Merges with existing config (existing values take precedence)
|
|
277
355
|
*
|
|
278
356
|
* @param params - Discovery parameters
|
|
357
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
279
358
|
* @returns Hydrated OIDC configuration ready for persistence
|
|
280
359
|
* @throws DiscoveryError on any failure
|
|
281
360
|
*/
|
|
282
361
|
async function discoverOIDCConfig(params) {
|
|
283
362
|
const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
|
|
284
363
|
const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
|
|
285
|
-
validateDiscoveryUrl(discoveryUrl);
|
|
364
|
+
validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
|
|
286
365
|
const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
|
|
287
366
|
validateDiscoveryDocument(discoveryDoc, issuer);
|
|
288
|
-
const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer);
|
|
367
|
+
const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
|
|
289
368
|
const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
|
|
290
369
|
return {
|
|
291
370
|
issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
|
|
@@ -313,19 +392,12 @@ function computeDiscoveryUrl(issuer) {
|
|
|
313
392
|
* Validate a discovery URL before fetching.
|
|
314
393
|
*
|
|
315
394
|
* @param url - The discovery URL to validate
|
|
395
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
316
396
|
* @throws DiscoveryError if URL is invalid
|
|
317
397
|
*/
|
|
318
|
-
function validateDiscoveryUrl(url) {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new DiscoveryError("discovery_invalid_url", `Discovery URL must use HTTP or HTTPS protocol: ${url}`, {
|
|
322
|
-
url,
|
|
323
|
-
protocol: parsed.protocol
|
|
324
|
-
});
|
|
325
|
-
} catch (error) {
|
|
326
|
-
if (error instanceof DiscoveryError) throw error;
|
|
327
|
-
throw new DiscoveryError("discovery_invalid_url", `Invalid discovery URL: ${url}`, { url }, { cause: error });
|
|
328
|
-
}
|
|
398
|
+
function validateDiscoveryUrl(url, isTrustedOrigin) {
|
|
399
|
+
const discoveryEndpoint = parseURL("discoveryEndpoint", url).toString();
|
|
400
|
+
if (!isTrustedOrigin(discoveryEndpoint)) throw new DiscoveryError("discovery_untrusted_origin", `The main discovery endpoint "${discoveryEndpoint}" is not trusted by your trusted origins configuration.`, { url: discoveryEndpoint });
|
|
329
401
|
}
|
|
330
402
|
/**
|
|
331
403
|
* Fetch the OIDC discovery document from the IdP.
|
|
@@ -399,22 +471,76 @@ function validateDiscoveryDocument(doc, configuredIssuer) {
|
|
|
399
471
|
/**
|
|
400
472
|
* Normalize URLs in the discovery document.
|
|
401
473
|
*
|
|
402
|
-
* @param
|
|
403
|
-
* @param
|
|
474
|
+
* @param document - The discovery document
|
|
475
|
+
* @param issuer - The base issuer URL
|
|
476
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
404
477
|
* @returns The normalized discovery document
|
|
405
478
|
*/
|
|
406
|
-
function normalizeDiscoveryUrls(
|
|
479
|
+
function normalizeDiscoveryUrls(document, issuer, isTrustedOrigin) {
|
|
480
|
+
const doc = { ...document };
|
|
481
|
+
doc.token_endpoint = normalizeAndValidateUrl("token_endpoint", doc.token_endpoint, issuer, isTrustedOrigin);
|
|
482
|
+
doc.authorization_endpoint = normalizeAndValidateUrl("authorization_endpoint", doc.authorization_endpoint, issuer, isTrustedOrigin);
|
|
483
|
+
doc.jwks_uri = normalizeAndValidateUrl("jwks_uri", doc.jwks_uri, issuer, isTrustedOrigin);
|
|
484
|
+
if (doc.userinfo_endpoint) doc.userinfo_endpoint = normalizeAndValidateUrl("userinfo_endpoint", doc.userinfo_endpoint, issuer, isTrustedOrigin);
|
|
485
|
+
if (doc.revocation_endpoint) doc.revocation_endpoint = normalizeAndValidateUrl("revocation_endpoint", doc.revocation_endpoint, issuer, isTrustedOrigin);
|
|
486
|
+
if (doc.end_session_endpoint) doc.end_session_endpoint = normalizeAndValidateUrl("end_session_endpoint", doc.end_session_endpoint, issuer, isTrustedOrigin);
|
|
487
|
+
if (doc.introspection_endpoint) doc.introspection_endpoint = normalizeAndValidateUrl("introspection_endpoint", doc.introspection_endpoint, issuer, isTrustedOrigin);
|
|
407
488
|
return doc;
|
|
408
489
|
}
|
|
409
490
|
/**
|
|
491
|
+
* Normalizes and validates a single URL endpoint
|
|
492
|
+
* @param name The url name
|
|
493
|
+
* @param endpoint The url to validate
|
|
494
|
+
* @param issuer The issuer base url
|
|
495
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
496
|
+
* @returns
|
|
497
|
+
*/
|
|
498
|
+
function normalizeAndValidateUrl(name, endpoint, issuer, isTrustedOrigin) {
|
|
499
|
+
const url = normalizeUrl(name, endpoint, issuer);
|
|
500
|
+
if (!isTrustedOrigin(url)) throw new DiscoveryError("discovery_untrusted_origin", `The ${name} "${url}" is not trusted by your trusted origins configuration.`, {
|
|
501
|
+
endpoint: name,
|
|
502
|
+
url
|
|
503
|
+
});
|
|
504
|
+
return url;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
410
507
|
* Normalize a single URL endpoint.
|
|
411
508
|
*
|
|
509
|
+
* @param name - The endpoint name (e.g token_endpoint)
|
|
412
510
|
* @param endpoint - The endpoint URL to normalize
|
|
413
|
-
* @param
|
|
511
|
+
* @param issuer - The base issuer URL
|
|
414
512
|
* @returns The normalized endpoint URL
|
|
415
513
|
*/
|
|
416
|
-
function normalizeUrl(endpoint,
|
|
417
|
-
|
|
514
|
+
function normalizeUrl(name, endpoint, issuer) {
|
|
515
|
+
try {
|
|
516
|
+
return parseURL(name, endpoint).toString();
|
|
517
|
+
} catch {
|
|
518
|
+
const issuerURL = parseURL(name, issuer);
|
|
519
|
+
const basePath = issuerURL.pathname.replace(/\/+$/, "");
|
|
520
|
+
const endpointPath = endpoint.replace(/^\/+/, "");
|
|
521
|
+
return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Parses the given URL or throws in case of invalid or unsupported protocols
|
|
526
|
+
*
|
|
527
|
+
* @param name the url name
|
|
528
|
+
* @param endpoint the endpoint url
|
|
529
|
+
* @param [base] optional base path
|
|
530
|
+
* @returns
|
|
531
|
+
*/
|
|
532
|
+
function parseURL(name, endpoint, base) {
|
|
533
|
+
let endpointURL;
|
|
534
|
+
try {
|
|
535
|
+
endpointURL = new URL(endpoint, base);
|
|
536
|
+
if (endpointURL.protocol === "http:" || endpointURL.protocol === "https:") return endpointURL;
|
|
537
|
+
} catch (error) {
|
|
538
|
+
throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
|
|
539
|
+
}
|
|
540
|
+
throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must use the http or https supported protocols: ${endpoint}`, {
|
|
541
|
+
url: endpoint,
|
|
542
|
+
protocol: endpointURL.protocol
|
|
543
|
+
});
|
|
418
544
|
}
|
|
419
545
|
/**
|
|
420
546
|
* Select the token endpoint authentication method.
|
|
@@ -492,6 +618,10 @@ function mapDiscoveryErrorToAPIError(error) {
|
|
|
492
618
|
message: `Invalid OIDC discovery URL: ${error.message}`,
|
|
493
619
|
code: error.code
|
|
494
620
|
});
|
|
621
|
+
case "discovery_untrusted_origin": return new APIError("BAD_REQUEST", {
|
|
622
|
+
message: `Untrusted OIDC discovery URL: ${error.message}`,
|
|
623
|
+
code: error.code
|
|
624
|
+
});
|
|
495
625
|
case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
|
|
496
626
|
message: `OIDC discovery returned invalid data: ${error.message}`,
|
|
497
627
|
code: error.code
|
|
@@ -517,6 +647,146 @@ function mapDiscoveryErrorToAPIError(error) {
|
|
|
517
647
|
}
|
|
518
648
|
}
|
|
519
649
|
|
|
650
|
+
//#endregion
|
|
651
|
+
//#region src/saml/algorithms.ts
|
|
652
|
+
const SignatureAlgorithm = {
|
|
653
|
+
RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
|
|
654
|
+
RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
|
|
655
|
+
RSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
|
|
656
|
+
RSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
|
|
657
|
+
ECDSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
|
|
658
|
+
ECDSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
|
|
659
|
+
ECDSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"
|
|
660
|
+
};
|
|
661
|
+
const DigestAlgorithm = {
|
|
662
|
+
SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
|
|
663
|
+
SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
|
|
664
|
+
SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
|
|
665
|
+
SHA512: "http://www.w3.org/2001/04/xmlenc#sha512"
|
|
666
|
+
};
|
|
667
|
+
const KeyEncryptionAlgorithm = {
|
|
668
|
+
RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
|
|
669
|
+
RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
|
|
670
|
+
RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
|
|
671
|
+
};
|
|
672
|
+
const DataEncryptionAlgorithm = {
|
|
673
|
+
TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
|
|
674
|
+
AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
|
|
675
|
+
AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
|
|
676
|
+
AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
|
|
677
|
+
AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
|
|
678
|
+
AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
|
|
679
|
+
AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
|
|
680
|
+
};
|
|
681
|
+
const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
|
|
682
|
+
const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
|
|
683
|
+
const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
|
|
684
|
+
const SECURE_SIGNATURE_ALGORITHMS = [
|
|
685
|
+
SignatureAlgorithm.RSA_SHA256,
|
|
686
|
+
SignatureAlgorithm.RSA_SHA384,
|
|
687
|
+
SignatureAlgorithm.RSA_SHA512,
|
|
688
|
+
SignatureAlgorithm.ECDSA_SHA256,
|
|
689
|
+
SignatureAlgorithm.ECDSA_SHA384,
|
|
690
|
+
SignatureAlgorithm.ECDSA_SHA512
|
|
691
|
+
];
|
|
692
|
+
const xmlParser = new XMLParser({
|
|
693
|
+
ignoreAttributes: false,
|
|
694
|
+
attributeNamePrefix: "@_",
|
|
695
|
+
removeNSPrefix: true
|
|
696
|
+
});
|
|
697
|
+
function findNode(obj, nodeName) {
|
|
698
|
+
if (!obj || typeof obj !== "object") return null;
|
|
699
|
+
const record = obj;
|
|
700
|
+
if (nodeName in record) return record[nodeName];
|
|
701
|
+
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
|
|
702
|
+
const found = findNode(item, nodeName);
|
|
703
|
+
if (found) return found;
|
|
704
|
+
}
|
|
705
|
+
else if (typeof value === "object" && value !== null) {
|
|
706
|
+
const found = findNode(value, nodeName);
|
|
707
|
+
if (found) return found;
|
|
708
|
+
}
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
function extractEncryptionAlgorithms(xml) {
|
|
712
|
+
try {
|
|
713
|
+
const parsed = xmlParser.parse(xml);
|
|
714
|
+
const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
|
|
715
|
+
const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
|
|
716
|
+
return {
|
|
717
|
+
keyEncryption: keyAlg || null,
|
|
718
|
+
dataEncryption: dataAlg || null
|
|
719
|
+
};
|
|
720
|
+
} catch {
|
|
721
|
+
return {
|
|
722
|
+
keyEncryption: null,
|
|
723
|
+
dataEncryption: null
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
function hasEncryptedAssertion(xml) {
|
|
728
|
+
try {
|
|
729
|
+
return findNode(xmlParser.parse(xml), "EncryptedAssertion") !== null;
|
|
730
|
+
} catch {
|
|
731
|
+
return false;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
function handleDeprecatedAlgorithm(message, behavior, errorCode) {
|
|
735
|
+
switch (behavior) {
|
|
736
|
+
case "reject": throw new APIError("BAD_REQUEST", {
|
|
737
|
+
message,
|
|
738
|
+
code: errorCode
|
|
739
|
+
});
|
|
740
|
+
case "warn":
|
|
741
|
+
console.warn(`[SAML Security Warning] ${message}`);
|
|
742
|
+
break;
|
|
743
|
+
case "allow": break;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
function validateSignatureAlgorithm(algorithm, options = {}) {
|
|
747
|
+
if (!algorithm) return;
|
|
748
|
+
const { onDeprecated = "warn", allowedSignatureAlgorithms } = options;
|
|
749
|
+
if (allowedSignatureAlgorithms) {
|
|
750
|
+
if (!allowedSignatureAlgorithms.includes(algorithm)) throw new APIError("BAD_REQUEST", {
|
|
751
|
+
message: `SAML signature algorithm not in allow-list: ${algorithm}`,
|
|
752
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
753
|
+
});
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(algorithm)) {
|
|
757
|
+
handleDeprecatedAlgorithm(`SAML response uses deprecated signature algorithm: ${algorithm}. Please configure your IdP to use SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
if (!SECURE_SIGNATURE_ALGORITHMS.includes(algorithm)) throw new APIError("BAD_REQUEST", {
|
|
761
|
+
message: `SAML signature algorithm not recognized: ${algorithm}`,
|
|
762
|
+
code: "SAML_UNKNOWN_ALGORITHM"
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
function validateEncryptionAlgorithms(algorithms, options = {}) {
|
|
766
|
+
const { onDeprecated = "warn", allowedKeyEncryptionAlgorithms, allowedDataEncryptionAlgorithms } = options;
|
|
767
|
+
const { keyEncryption, dataEncryption } = algorithms;
|
|
768
|
+
if (keyEncryption) {
|
|
769
|
+
if (allowedKeyEncryptionAlgorithms) {
|
|
770
|
+
if (!allowedKeyEncryptionAlgorithms.includes(keyEncryption)) throw new APIError("BAD_REQUEST", {
|
|
771
|
+
message: `SAML key encryption algorithm not in allow-list: ${keyEncryption}`,
|
|
772
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
773
|
+
});
|
|
774
|
+
} else if (DEPRECATED_KEY_ENCRYPTION_ALGORITHMS.includes(keyEncryption)) handleDeprecatedAlgorithm(`SAML response uses deprecated key encryption algorithm: ${keyEncryption}. Please configure your IdP to use RSA-OAEP.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
|
|
775
|
+
}
|
|
776
|
+
if (dataEncryption) {
|
|
777
|
+
if (allowedDataEncryptionAlgorithms) {
|
|
778
|
+
if (!allowedDataEncryptionAlgorithms.includes(dataEncryption)) throw new APIError("BAD_REQUEST", {
|
|
779
|
+
message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
|
|
780
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
781
|
+
});
|
|
782
|
+
} else if (DEPRECATED_DATA_ENCRYPTION_ALGORITHMS.includes(dataEncryption)) handleDeprecatedAlgorithm(`SAML response uses deprecated data encryption algorithm: ${dataEncryption}. Please configure your IdP to use AES-GCM.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
function validateSAMLAlgorithms(response, options) {
|
|
786
|
+
validateSignatureAlgorithm(response.sigAlg, options);
|
|
787
|
+
if (hasEncryptedAssertion(response.samlContent)) validateEncryptionAlgorithms(extractEncryptionAlgorithms(response.samlContent), options);
|
|
788
|
+
}
|
|
789
|
+
|
|
520
790
|
//#endregion
|
|
521
791
|
//#region src/utils.ts
|
|
522
792
|
/**
|
|
@@ -547,9 +817,6 @@ const validateEmailDomain = (email, domain) => {
|
|
|
547
817
|
|
|
548
818
|
//#endregion
|
|
549
819
|
//#region src/routes/sso.ts
|
|
550
|
-
const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
|
|
551
|
-
/** Default clock skew tolerance: 5 minutes */
|
|
552
|
-
const DEFAULT_CLOCK_SKEW_MS = 300 * 1e3;
|
|
553
820
|
/**
|
|
554
821
|
* Validates SAML assertion timestamp conditions (NotBefore/NotOnOrAfter).
|
|
555
822
|
* Prevents acceptance of expired or future-dated assertions.
|
|
@@ -589,6 +856,27 @@ function validateSAMLTimestamp(conditions, options = {}) {
|
|
|
589
856
|
});
|
|
590
857
|
}
|
|
591
858
|
}
|
|
859
|
+
/**
|
|
860
|
+
* Extracts the Assertion ID from a SAML response XML.
|
|
861
|
+
* Returns null if the assertion ID cannot be found.
|
|
862
|
+
*/
|
|
863
|
+
function extractAssertionId(samlContent) {
|
|
864
|
+
try {
|
|
865
|
+
const parsed = new XMLParser({
|
|
866
|
+
ignoreAttributes: false,
|
|
867
|
+
attributeNamePrefix: "@_",
|
|
868
|
+
removeNSPrefix: true
|
|
869
|
+
}).parse(samlContent);
|
|
870
|
+
const response = parsed.Response || parsed["samlp:Response"];
|
|
871
|
+
if (!response) return null;
|
|
872
|
+
const rawAssertion = response.Assertion || response["saml:Assertion"];
|
|
873
|
+
const assertion = Array.isArray(rawAssertion) ? rawAssertion[0] : rawAssertion;
|
|
874
|
+
if (!assertion) return null;
|
|
875
|
+
return assertion["@_ID"] || null;
|
|
876
|
+
} catch {
|
|
877
|
+
return null;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
592
880
|
const spMetadataQuerySchema = z.object({
|
|
593
881
|
providerId: z.string(),
|
|
594
882
|
format: z.enum(["xml", "json"]).default("xml")
|
|
@@ -918,7 +1206,8 @@ const registerSSOProvider = (options) => {
|
|
|
918
1206
|
jwksEndpoint: body.oidcConfig.jwksEndpoint,
|
|
919
1207
|
userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
|
|
920
1208
|
tokenEndpointAuthentication: body.oidcConfig.tokenEndpointAuthentication
|
|
921
|
-
}
|
|
1209
|
+
},
|
|
1210
|
+
isTrustedOrigin: ctx.context.isTrustedOrigin
|
|
922
1211
|
});
|
|
923
1212
|
} catch (error) {
|
|
924
1213
|
if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
|
|
@@ -1195,7 +1484,7 @@ const signInSSO = (options) => {
|
|
|
1195
1484
|
});
|
|
1196
1485
|
const loginRequest = sp.createLoginRequest(idp, "redirect");
|
|
1197
1486
|
if (!loginRequest) throw new APIError("BAD_REQUEST", { message: "Invalid SAML request" });
|
|
1198
|
-
if (loginRequest.id &&
|
|
1487
|
+
if (loginRequest.id && options?.saml?.enableInResponseToValidation) {
|
|
1199
1488
|
const ttl = options?.saml?.requestTTL ?? DEFAULT_AUTHN_REQUEST_TTL_MS;
|
|
1200
1489
|
const record = {
|
|
1201
1490
|
id: loginRequest.id,
|
|
@@ -1203,8 +1492,7 @@ const signInSSO = (options) => {
|
|
|
1203
1492
|
createdAt: Date.now(),
|
|
1204
1493
|
expiresAt: Date.now() + ttl
|
|
1205
1494
|
};
|
|
1206
|
-
|
|
1207
|
-
else await ctx.context.internalAdapter.createVerificationValue({
|
|
1495
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
1208
1496
|
identifier: `${AUTHN_REQUEST_KEY_PREFIX}${record.id}`,
|
|
1209
1497
|
value: JSON.stringify(record),
|
|
1210
1498
|
expiresAt: new Date(record.expiresAt)
|
|
@@ -1362,37 +1650,20 @@ const callbackSSO = (options) => {
|
|
|
1362
1650
|
token: tokenResponse,
|
|
1363
1651
|
provider
|
|
1364
1652
|
});
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
userInfo,
|
|
1380
|
-
token: tokenResponse,
|
|
1381
|
-
provider
|
|
1382
|
-
}) : options?.organizationProvisioning?.defaultRole || "member";
|
|
1383
|
-
await ctx.context.adapter.create({
|
|
1384
|
-
model: "member",
|
|
1385
|
-
data: {
|
|
1386
|
-
organizationId: provider.organizationId,
|
|
1387
|
-
userId: user.id,
|
|
1388
|
-
role,
|
|
1389
|
-
createdAt: /* @__PURE__ */ new Date(),
|
|
1390
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
1391
|
-
}
|
|
1392
|
-
});
|
|
1393
|
-
}
|
|
1394
|
-
}
|
|
1395
|
-
}
|
|
1653
|
+
await assignOrganizationFromProvider(ctx, {
|
|
1654
|
+
user,
|
|
1655
|
+
profile: {
|
|
1656
|
+
providerType: "oidc",
|
|
1657
|
+
providerId: provider.providerId,
|
|
1658
|
+
accountId: userInfo.id,
|
|
1659
|
+
email: userInfo.email,
|
|
1660
|
+
emailVerified: Boolean(userInfo.emailVerified),
|
|
1661
|
+
rawAttributes: userInfo
|
|
1662
|
+
},
|
|
1663
|
+
provider,
|
|
1664
|
+
token: tokenResponse,
|
|
1665
|
+
provisioningOptions: options?.organizationProvisioning
|
|
1666
|
+
});
|
|
1396
1667
|
await setSessionCookie(ctx, {
|
|
1397
1668
|
session,
|
|
1398
1669
|
user
|
|
@@ -1514,25 +1785,23 @@ const callbackSSOSAML = (options) => {
|
|
|
1514
1785
|
});
|
|
1515
1786
|
}
|
|
1516
1787
|
const { extract } = parsedResponse;
|
|
1788
|
+
validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
|
|
1517
1789
|
validateSAMLTimestamp(extract.conditions, {
|
|
1518
1790
|
clockSkew: options?.saml?.clockSkew,
|
|
1519
1791
|
requireTimestamps: options?.saml?.requireTimestamps,
|
|
1520
1792
|
logger: ctx.context.logger
|
|
1521
1793
|
});
|
|
1522
1794
|
const inResponseTo = extract.inResponseTo;
|
|
1523
|
-
if (options?.saml?.
|
|
1795
|
+
if (options?.saml?.enableInResponseToValidation) {
|
|
1524
1796
|
const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
|
|
1525
1797
|
if (inResponseTo) {
|
|
1526
1798
|
let storedRequest = null;
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
if (
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
} catch {
|
|
1534
|
-
storedRequest = null;
|
|
1535
|
-
}
|
|
1799
|
+
const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1800
|
+
if (verification) try {
|
|
1801
|
+
storedRequest = JSON.parse(verification.value);
|
|
1802
|
+
if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
|
|
1803
|
+
} catch {
|
|
1804
|
+
storedRequest = null;
|
|
1536
1805
|
}
|
|
1537
1806
|
if (!storedRequest) {
|
|
1538
1807
|
ctx.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
|
|
@@ -1548,19 +1817,55 @@ const callbackSSOSAML = (options) => {
|
|
|
1548
1817
|
expectedProvider: storedRequest.providerId,
|
|
1549
1818
|
actualProvider: provider.providerId
|
|
1550
1819
|
});
|
|
1551
|
-
|
|
1552
|
-
else await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1820
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1553
1821
|
const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1554
1822
|
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
|
|
1555
1823
|
}
|
|
1556
|
-
|
|
1557
|
-
else await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1824
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1558
1825
|
} else if (!allowIdpInitiated) {
|
|
1559
1826
|
ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: provider.providerId });
|
|
1560
1827
|
const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1561
1828
|
throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
|
|
1562
1829
|
}
|
|
1563
1830
|
}
|
|
1831
|
+
const samlContent = parsedResponse.samlContent;
|
|
1832
|
+
const assertionId = samlContent ? extractAssertionId(samlContent) : null;
|
|
1833
|
+
if (assertionId) {
|
|
1834
|
+
const issuer = idp.entityMeta.getEntityID();
|
|
1835
|
+
const conditions = extract.conditions;
|
|
1836
|
+
const clockSkew = options?.saml?.clockSkew ?? DEFAULT_CLOCK_SKEW_MS;
|
|
1837
|
+
const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
|
|
1838
|
+
const existingAssertion = await ctx.context.internalAdapter.findVerificationValue(`${USED_ASSERTION_KEY_PREFIX}${assertionId}`);
|
|
1839
|
+
let isReplay = false;
|
|
1840
|
+
if (existingAssertion) try {
|
|
1841
|
+
if (JSON.parse(existingAssertion.value).expiresAt >= Date.now()) isReplay = true;
|
|
1842
|
+
} catch (error) {
|
|
1843
|
+
ctx.context.logger.warn("Failed to parse stored assertion record", {
|
|
1844
|
+
assertionId,
|
|
1845
|
+
error
|
|
1846
|
+
});
|
|
1847
|
+
}
|
|
1848
|
+
if (isReplay) {
|
|
1849
|
+
ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
|
|
1850
|
+
assertionId,
|
|
1851
|
+
issuer,
|
|
1852
|
+
providerId: provider.providerId
|
|
1853
|
+
});
|
|
1854
|
+
const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1855
|
+
throw ctx.redirect(`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
1856
|
+
}
|
|
1857
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
1858
|
+
identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
|
|
1859
|
+
value: JSON.stringify({
|
|
1860
|
+
assertionId,
|
|
1861
|
+
issuer,
|
|
1862
|
+
providerId: provider.providerId,
|
|
1863
|
+
usedAt: Date.now(),
|
|
1864
|
+
expiresAt
|
|
1865
|
+
}),
|
|
1866
|
+
expiresAt: new Date(expiresAt)
|
|
1867
|
+
});
|
|
1868
|
+
} else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId: provider.providerId });
|
|
1564
1869
|
const attributes = extract.attributes || {};
|
|
1565
1870
|
const mapping = parsedSamlConfig.mapping ?? {};
|
|
1566
1871
|
const userInfo = {
|
|
@@ -1579,100 +1884,49 @@ const callbackSSOSAML = (options) => {
|
|
|
1579
1884
|
});
|
|
1580
1885
|
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
1581
1886
|
}
|
|
1582
|
-
|
|
1583
|
-
const
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
field: "email",
|
|
1587
|
-
value: userInfo.email
|
|
1588
|
-
}]
|
|
1589
|
-
});
|
|
1590
|
-
if (existingUser) {
|
|
1591
|
-
if (!await ctx.context.adapter.findOne({
|
|
1592
|
-
model: "account",
|
|
1593
|
-
where: [
|
|
1594
|
-
{
|
|
1595
|
-
field: "userId",
|
|
1596
|
-
value: existingUser.id
|
|
1597
|
-
},
|
|
1598
|
-
{
|
|
1599
|
-
field: "providerId",
|
|
1600
|
-
value: provider.providerId
|
|
1601
|
-
},
|
|
1602
|
-
{
|
|
1603
|
-
field: "accountId",
|
|
1604
|
-
value: userInfo.id
|
|
1605
|
-
}
|
|
1606
|
-
]
|
|
1607
|
-
})) {
|
|
1608
|
-
if (!(ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain))) {
|
|
1609
|
-
const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1610
|
-
throw ctx.redirect(`${redirectUrl}?error=account_not_linked`);
|
|
1611
|
-
}
|
|
1612
|
-
await ctx.context.internalAdapter.createAccount({
|
|
1613
|
-
userId: existingUser.id,
|
|
1614
|
-
providerId: provider.providerId,
|
|
1615
|
-
accountId: userInfo.id,
|
|
1616
|
-
accessToken: "",
|
|
1617
|
-
refreshToken: ""
|
|
1618
|
-
});
|
|
1619
|
-
}
|
|
1620
|
-
user = existingUser;
|
|
1621
|
-
} else {
|
|
1622
|
-
if (options?.disableImplicitSignUp) throw new APIError("UNAUTHORIZED", { message: "User not found and implicit sign up is disabled for this provider" });
|
|
1623
|
-
user = await ctx.context.internalAdapter.createUser({
|
|
1887
|
+
const isTrustedProvider = !!ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
1888
|
+
const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1889
|
+
const result = await handleOAuthUserInfo(ctx, {
|
|
1890
|
+
userInfo: {
|
|
1624
1891
|
email: userInfo.email,
|
|
1625
|
-
name: userInfo.name,
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1892
|
+
name: userInfo.name || userInfo.email,
|
|
1893
|
+
id: userInfo.id,
|
|
1894
|
+
emailVerified: Boolean(userInfo.emailVerified)
|
|
1895
|
+
},
|
|
1896
|
+
account: {
|
|
1630
1897
|
providerId: provider.providerId,
|
|
1631
1898
|
accountId: userInfo.id,
|
|
1632
1899
|
accessToken: "",
|
|
1633
1900
|
refreshToken: ""
|
|
1634
|
-
}
|
|
1635
|
-
|
|
1901
|
+
},
|
|
1902
|
+
callbackURL: callbackUrl,
|
|
1903
|
+
disableSignUp: options?.disableImplicitSignUp,
|
|
1904
|
+
isTrustedProvider
|
|
1905
|
+
});
|
|
1906
|
+
if (result.error) throw ctx.redirect(`${callbackUrl}?error=${result.error.split(" ").join("_")}`);
|
|
1907
|
+
const { session, user } = result.data;
|
|
1636
1908
|
if (options?.provisionUser) await options.provisionUser({
|
|
1637
1909
|
user,
|
|
1638
1910
|
userInfo,
|
|
1639
1911
|
provider
|
|
1640
1912
|
});
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
user,
|
|
1655
|
-
userInfo,
|
|
1656
|
-
provider
|
|
1657
|
-
}) : options?.organizationProvisioning?.defaultRole || "member";
|
|
1658
|
-
await ctx.context.adapter.create({
|
|
1659
|
-
model: "member",
|
|
1660
|
-
data: {
|
|
1661
|
-
organizationId: provider.organizationId,
|
|
1662
|
-
userId: user.id,
|
|
1663
|
-
role,
|
|
1664
|
-
createdAt: /* @__PURE__ */ new Date(),
|
|
1665
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
1666
|
-
}
|
|
1667
|
-
});
|
|
1668
|
-
}
|
|
1669
|
-
}
|
|
1670
|
-
}
|
|
1913
|
+
await assignOrganizationFromProvider(ctx, {
|
|
1914
|
+
user,
|
|
1915
|
+
profile: {
|
|
1916
|
+
providerType: "saml",
|
|
1917
|
+
providerId: provider.providerId,
|
|
1918
|
+
accountId: userInfo.id,
|
|
1919
|
+
email: userInfo.email,
|
|
1920
|
+
emailVerified: Boolean(userInfo.emailVerified),
|
|
1921
|
+
rawAttributes: attributes
|
|
1922
|
+
},
|
|
1923
|
+
provider,
|
|
1924
|
+
provisioningOptions: options?.organizationProvisioning
|
|
1925
|
+
});
|
|
1671
1926
|
await setSessionCookie(ctx, {
|
|
1672
|
-
session
|
|
1927
|
+
session,
|
|
1673
1928
|
user
|
|
1674
1929
|
});
|
|
1675
|
-
const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1676
1930
|
throw ctx.redirect(callbackUrl);
|
|
1677
1931
|
});
|
|
1678
1932
|
};
|
|
@@ -1765,25 +2019,23 @@ const acsEndpoint = (options) => {
|
|
|
1765
2019
|
});
|
|
1766
2020
|
}
|
|
1767
2021
|
const { extract } = parsedResponse;
|
|
2022
|
+
validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
|
|
1768
2023
|
validateSAMLTimestamp(extract.conditions, {
|
|
1769
2024
|
clockSkew: options?.saml?.clockSkew,
|
|
1770
2025
|
requireTimestamps: options?.saml?.requireTimestamps,
|
|
1771
2026
|
logger: ctx.context.logger
|
|
1772
2027
|
});
|
|
1773
2028
|
const inResponseToAcs = extract.inResponseTo;
|
|
1774
|
-
if (options?.saml?.
|
|
2029
|
+
if (options?.saml?.enableInResponseToValidation) {
|
|
1775
2030
|
const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
|
|
1776
2031
|
if (inResponseToAcs) {
|
|
1777
2032
|
let storedRequest = null;
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
if (
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
} catch {
|
|
1785
|
-
storedRequest = null;
|
|
1786
|
-
}
|
|
2033
|
+
const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
|
|
2034
|
+
if (verification) try {
|
|
2035
|
+
storedRequest = JSON.parse(verification.value);
|
|
2036
|
+
if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
|
|
2037
|
+
} catch {
|
|
2038
|
+
storedRequest = null;
|
|
1787
2039
|
}
|
|
1788
2040
|
if (!storedRequest) {
|
|
1789
2041
|
ctx.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
|
|
@@ -1799,19 +2051,54 @@ const acsEndpoint = (options) => {
|
|
|
1799
2051
|
expectedProvider: storedRequest.providerId,
|
|
1800
2052
|
actualProvider: providerId
|
|
1801
2053
|
});
|
|
1802
|
-
|
|
1803
|
-
else await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
|
|
2054
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
|
|
1804
2055
|
const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1805
2056
|
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
|
|
1806
2057
|
}
|
|
1807
|
-
|
|
1808
|
-
else await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
|
|
2058
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
|
|
1809
2059
|
} else if (!allowIdpInitiated) {
|
|
1810
2060
|
ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId });
|
|
1811
2061
|
const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1812
2062
|
throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
|
|
1813
2063
|
}
|
|
1814
2064
|
}
|
|
2065
|
+
const assertionIdAcs = extractAssertionId(Buffer.from(SAMLResponse, "base64").toString("utf-8"));
|
|
2066
|
+
if (assertionIdAcs) {
|
|
2067
|
+
const issuer = idp.entityMeta.getEntityID();
|
|
2068
|
+
const conditions = extract.conditions;
|
|
2069
|
+
const clockSkew = options?.saml?.clockSkew ?? DEFAULT_CLOCK_SKEW_MS;
|
|
2070
|
+
const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
|
|
2071
|
+
const existingAssertion = await ctx.context.internalAdapter.findVerificationValue(`${USED_ASSERTION_KEY_PREFIX}${assertionIdAcs}`);
|
|
2072
|
+
let isReplay = false;
|
|
2073
|
+
if (existingAssertion) try {
|
|
2074
|
+
if (JSON.parse(existingAssertion.value).expiresAt >= Date.now()) isReplay = true;
|
|
2075
|
+
} catch (error) {
|
|
2076
|
+
ctx.context.logger.warn("Failed to parse stored assertion record", {
|
|
2077
|
+
assertionId: assertionIdAcs,
|
|
2078
|
+
error
|
|
2079
|
+
});
|
|
2080
|
+
}
|
|
2081
|
+
if (isReplay) {
|
|
2082
|
+
ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
|
|
2083
|
+
assertionId: assertionIdAcs,
|
|
2084
|
+
issuer,
|
|
2085
|
+
providerId
|
|
2086
|
+
});
|
|
2087
|
+
const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2088
|
+
throw ctx.redirect(`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
2089
|
+
}
|
|
2090
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
2091
|
+
identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionIdAcs}`,
|
|
2092
|
+
value: JSON.stringify({
|
|
2093
|
+
assertionId: assertionIdAcs,
|
|
2094
|
+
issuer,
|
|
2095
|
+
providerId,
|
|
2096
|
+
usedAt: Date.now(),
|
|
2097
|
+
expiresAt
|
|
2098
|
+
}),
|
|
2099
|
+
expiresAt: new Date(expiresAt)
|
|
2100
|
+
});
|
|
2101
|
+
} else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
|
|
1815
2102
|
const attributes = extract.attributes || {};
|
|
1816
2103
|
const mapping = parsedSamlConfig.mapping ?? {};
|
|
1817
2104
|
const userInfo = {
|
|
@@ -1830,99 +2117,49 @@ const acsEndpoint = (options) => {
|
|
|
1830
2117
|
});
|
|
1831
2118
|
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
1832
2119
|
}
|
|
1833
|
-
|
|
1834
|
-
const
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
field: "email",
|
|
1838
|
-
value: userInfo.email
|
|
1839
|
-
}]
|
|
1840
|
-
});
|
|
1841
|
-
if (existingUser) {
|
|
1842
|
-
if (!await ctx.context.adapter.findOne({
|
|
1843
|
-
model: "account",
|
|
1844
|
-
where: [
|
|
1845
|
-
{
|
|
1846
|
-
field: "userId",
|
|
1847
|
-
value: existingUser.id
|
|
1848
|
-
},
|
|
1849
|
-
{
|
|
1850
|
-
field: "providerId",
|
|
1851
|
-
value: provider.providerId
|
|
1852
|
-
},
|
|
1853
|
-
{
|
|
1854
|
-
field: "accountId",
|
|
1855
|
-
value: userInfo.id
|
|
1856
|
-
}
|
|
1857
|
-
]
|
|
1858
|
-
})) {
|
|
1859
|
-
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`);
|
|
1860
|
-
await ctx.context.internalAdapter.createAccount({
|
|
1861
|
-
userId: existingUser.id,
|
|
1862
|
-
providerId: provider.providerId,
|
|
1863
|
-
accountId: userInfo.id,
|
|
1864
|
-
accessToken: "",
|
|
1865
|
-
refreshToken: ""
|
|
1866
|
-
});
|
|
1867
|
-
}
|
|
1868
|
-
user = existingUser;
|
|
1869
|
-
} else {
|
|
1870
|
-
user = await ctx.context.internalAdapter.createUser({
|
|
2120
|
+
const isTrustedProvider = !!ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
2121
|
+
const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2122
|
+
const result = await handleOAuthUserInfo(ctx, {
|
|
2123
|
+
userInfo: {
|
|
1871
2124
|
email: userInfo.email,
|
|
1872
|
-
name: userInfo.name,
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
2125
|
+
name: userInfo.name || userInfo.email,
|
|
2126
|
+
id: userInfo.id,
|
|
2127
|
+
emailVerified: Boolean(userInfo.emailVerified)
|
|
2128
|
+
},
|
|
2129
|
+
account: {
|
|
1877
2130
|
providerId: provider.providerId,
|
|
1878
2131
|
accountId: userInfo.id,
|
|
1879
2132
|
accessToken: "",
|
|
1880
|
-
refreshToken: ""
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
}
|
|
2133
|
+
refreshToken: ""
|
|
2134
|
+
},
|
|
2135
|
+
callbackURL: callbackUrl,
|
|
2136
|
+
disableSignUp: options?.disableImplicitSignUp,
|
|
2137
|
+
isTrustedProvider
|
|
2138
|
+
});
|
|
2139
|
+
if (result.error) throw ctx.redirect(`${callbackUrl}?error=${result.error.split(" ").join("_")}`);
|
|
2140
|
+
const { session, user } = result.data;
|
|
1886
2141
|
if (options?.provisionUser) await options.provisionUser({
|
|
1887
2142
|
user,
|
|
1888
2143
|
userInfo,
|
|
1889
2144
|
provider
|
|
1890
2145
|
});
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
user,
|
|
1905
|
-
userInfo,
|
|
1906
|
-
provider
|
|
1907
|
-
}) : options?.organizationProvisioning?.defaultRole || "member";
|
|
1908
|
-
await ctx.context.adapter.create({
|
|
1909
|
-
model: "member",
|
|
1910
|
-
data: {
|
|
1911
|
-
organizationId: provider.organizationId,
|
|
1912
|
-
userId: user.id,
|
|
1913
|
-
role,
|
|
1914
|
-
createdAt: /* @__PURE__ */ new Date(),
|
|
1915
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
1916
|
-
}
|
|
1917
|
-
});
|
|
1918
|
-
}
|
|
1919
|
-
}
|
|
1920
|
-
}
|
|
2146
|
+
await assignOrganizationFromProvider(ctx, {
|
|
2147
|
+
user,
|
|
2148
|
+
profile: {
|
|
2149
|
+
providerType: "saml",
|
|
2150
|
+
providerId: provider.providerId,
|
|
2151
|
+
accountId: userInfo.id,
|
|
2152
|
+
email: userInfo.email,
|
|
2153
|
+
emailVerified: Boolean(userInfo.emailVerified),
|
|
2154
|
+
rawAttributes: attributes
|
|
2155
|
+
},
|
|
2156
|
+
provider,
|
|
2157
|
+
provisioningOptions: options?.organizationProvisioning
|
|
2158
|
+
});
|
|
1921
2159
|
await setSessionCookie(ctx, {
|
|
1922
|
-
session
|
|
2160
|
+
session,
|
|
1923
2161
|
user
|
|
1924
2162
|
});
|
|
1925
|
-
const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1926
2163
|
throw ctx.redirect(callbackUrl);
|
|
1927
2164
|
});
|
|
1928
2165
|
};
|
|
@@ -1956,6 +2193,20 @@ function sso(options) {
|
|
|
1956
2193
|
return {
|
|
1957
2194
|
id: "sso",
|
|
1958
2195
|
endpoints,
|
|
2196
|
+
hooks: { after: [{
|
|
2197
|
+
matcher(context) {
|
|
2198
|
+
return context.path?.startsWith("/callback/") ?? false;
|
|
2199
|
+
},
|
|
2200
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
2201
|
+
const newSession = ctx.context.newSession;
|
|
2202
|
+
if (!newSession?.user) return;
|
|
2203
|
+
if (!ctx.context.options.plugins?.find((plugin) => plugin.id === "organization")) return;
|
|
2204
|
+
await assignOrganizationByDomain(ctx, {
|
|
2205
|
+
user: newSession.user,
|
|
2206
|
+
provisioningOptions: options?.organizationProvisioning
|
|
2207
|
+
});
|
|
2208
|
+
})
|
|
2209
|
+
}] },
|
|
1959
2210
|
schema: { ssoProvider: {
|
|
1960
2211
|
modelName: options?.modelName ?? "ssoProvider",
|
|
1961
2212
|
fields: {
|
|
@@ -2008,4 +2259,4 @@ function sso(options) {
|
|
|
2008
2259
|
}
|
|
2009
2260
|
|
|
2010
2261
|
//#endregion
|
|
2011
|
-
export {
|
|
2262
|
+
export { DataEncryptionAlgorithm, DigestAlgorithm, DiscoveryError, KeyEncryptionAlgorithm, REQUIRED_DISCOVERY_FIELDS, SignatureAlgorithm, computeDiscoveryUrl, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };
|