@better-auth/sso 1.7.0-rc.0 → 1.7.0-rc.2
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/dist/index.mjs
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
1
|
-
import { t as PACKAGE_VERSION } from "./version-
|
|
1
|
+
import { t as PACKAGE_VERSION } from "./version-m-Zfjx6T.mjs";
|
|
2
2
|
import { APIError, addOAuthServerContext, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
|
|
3
3
|
import { XMLParser, XMLValidator } from "fast-xml-parser";
|
|
4
4
|
import { X509Certificate } from "node:crypto";
|
|
5
5
|
import { getHostname } from "tldts";
|
|
6
6
|
import { generateRandomString } from "better-auth/crypto";
|
|
7
7
|
import * as z from "zod";
|
|
8
|
-
import { getCurrentAdapter, runWithTransaction } from "@better-auth/core/context";
|
|
8
|
+
import { getCurrentAdapter, getCurrentDBAdapterAsyncLocalStorage, runWithTransaction } from "@better-auth/core/context";
|
|
9
9
|
import { filterOutputFields } from "@better-auth/core/utils/db";
|
|
10
10
|
import { classifyHost, isPublicRoutableHost } from "@better-auth/core/utils/host";
|
|
11
11
|
import { betterFetch } from "@better-fetch/fetch";
|
|
12
|
-
import { createRemoteJWKSet, customFetch,
|
|
13
|
-
import { base64 } from "@better-auth/utils/base64";
|
|
12
|
+
import { createRemoteJWKSet, customFetch, jwtVerify } from "jose";
|
|
13
|
+
import { base64, base64Url } from "@better-auth/utils/base64";
|
|
14
14
|
import { defineErrorCodes } from "@better-auth/core/utils/error-codes";
|
|
15
|
+
import * as samlifyNamespace from "samlify";
|
|
16
|
+
import samlifyDefault from "samlify";
|
|
15
17
|
import { parseInputData, toZodSchema } from "better-auth/db";
|
|
16
18
|
import { isAPIError } from "@better-auth/core/utils/is-api-error";
|
|
17
19
|
import { HIDE_METADATA, PRIVATE_KEY_JWT_SIGNING_ALGORITHMS, authorizationCodeRequest, createAuthorizationURL, createPrivateKeyJwtClientAssertionGetter, generateGenericState, generateState, getOAuth2Tokens, parseGenericState, parseState } from "better-auth";
|
|
18
|
-
import { deleteSessionCookie, setSessionCookie } from "better-auth/cookies";
|
|
20
|
+
import { deleteSessionCookie, setAccountCookie, setSessionCookie } from "better-auth/cookies";
|
|
19
21
|
import { additionalAuthorizationParamsSchema, handleOAuthUserInfo } from "better-auth/oauth2";
|
|
20
|
-
import
|
|
21
|
-
import samlifyDefault from "samlify";
|
|
22
|
+
import { createHash } from "@better-auth/utils/hash";
|
|
22
23
|
//#region src/constants.ts
|
|
23
24
|
/**
|
|
24
25
|
* SAML Constants
|
|
@@ -60,6 +61,8 @@ const DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024;
|
|
|
60
61
|
const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
|
|
61
62
|
//#endregion
|
|
62
63
|
//#region src/utils.ts
|
|
64
|
+
const unsafeSAMLRedirectPathPrefix = /^\/(?:\/|\\|%2f|%5c)/i;
|
|
65
|
+
const isSafeSAMLRedirectPath = (url) => url.startsWith("/") && !unsafeSAMLRedirectPathPrefix.test(url);
|
|
63
66
|
/**
|
|
64
67
|
* Safely parses a value that might be a JSON string or already a parsed object.
|
|
65
68
|
* This handles cases where ORMs like Drizzle might return already parsed objects
|
|
@@ -515,15 +518,21 @@ async function fetchOIDCEndpointResponse(name, endpoint, init, isTrustedOrigin)
|
|
|
515
518
|
if (isHttpRedirectStatus(response.status)) throwRedirectError(name, endpoint, response.status, response.headers.get("location"));
|
|
516
519
|
return response;
|
|
517
520
|
}
|
|
521
|
+
/** Enforces the OpenID Connect authorized-party rules for ID Tokens. */
|
|
522
|
+
function assertOIDCAuthorizedParty(payload, clientId) {
|
|
523
|
+
if (Array.isArray(payload.aud) && payload.aud.length > 1 && payload.azp === void 0 || payload.azp !== void 0 && payload.azp !== clientId) throw new Error("OIDC ID token authorized party does not match the client");
|
|
524
|
+
}
|
|
518
525
|
/**
|
|
519
526
|
* Validate an OIDC ID token using the same endpoint fetch policy as the rest of
|
|
520
527
|
* the SSO OIDC flow.
|
|
521
528
|
*/
|
|
522
529
|
async function validateOIDCIdToken(token, jwksEndpoint, options, isTrustedOrigin) {
|
|
523
|
-
|
|
530
|
+
const verified = await jwtVerify(token, createRemoteJWKSet(new URL(jwksEndpoint), { [customFetch]: (url, init) => fetchOIDCEndpointResponse("jwksEndpoint", url, init, isTrustedOrigin) }), {
|
|
524
531
|
audience: options.audience,
|
|
525
532
|
issuer: options.issuer
|
|
526
533
|
});
|
|
534
|
+
if (typeof options.audience === "string") assertOIDCAuthorizedParty(verified.payload, options.audience);
|
|
535
|
+
return verified;
|
|
527
536
|
}
|
|
528
537
|
/**
|
|
529
538
|
* Fetch the OIDC discovery document from the IdP.
|
|
@@ -1301,8 +1310,131 @@ function validateAudience(c, ctx) {
|
|
|
1301
1310
|
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience mismatch"));
|
|
1302
1311
|
}
|
|
1303
1312
|
}
|
|
1313
|
+
const saml = typeof samlifyNamespace.SPMetadata === "function" && typeof samlifyNamespace.setSchemaValidator === "function" ? samlifyNamespace : samlifyDefault ?? samlifyNamespace;
|
|
1314
|
+
//#endregion
|
|
1315
|
+
//#region src/routes/helpers.ts
|
|
1316
|
+
/**
|
|
1317
|
+
* Same as `normalizePem`, but applied across the resolved list of IdP signing
|
|
1318
|
+
* certificates so multi-cert rotation configs survive the line-trim step.
|
|
1319
|
+
*/
|
|
1320
|
+
function normalizePemList(certs) {
|
|
1321
|
+
if (!certs) return certs;
|
|
1322
|
+
return certs.map((pem) => normalizePem(pem) ?? pem);
|
|
1323
|
+
}
|
|
1324
|
+
async function findSAMLProvider(providerId, options, adapter) {
|
|
1325
|
+
if (options?.defaultSSO?.length) {
|
|
1326
|
+
const match = options.defaultSSO.find((p) => p.providerId === providerId);
|
|
1327
|
+
if (match) return {
|
|
1328
|
+
...match,
|
|
1329
|
+
userId: "default",
|
|
1330
|
+
issuer: match.samlConfig?.issuer || "",
|
|
1331
|
+
...options.domainVerification?.enabled ? { domainVerified: true } : {}
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
const res = await adapter.findOne({
|
|
1335
|
+
model: "ssoProvider",
|
|
1336
|
+
where: [{
|
|
1337
|
+
field: "providerId",
|
|
1338
|
+
value: providerId
|
|
1339
|
+
}]
|
|
1340
|
+
});
|
|
1341
|
+
if (!res) return null;
|
|
1342
|
+
return {
|
|
1343
|
+
...res,
|
|
1344
|
+
samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
function createSP(config, baseURL, providerId, opts) {
|
|
1348
|
+
const spData = config.spMetadata;
|
|
1349
|
+
const sloLocation = `${baseURL}/sso/saml2/sp/slo/${providerId}`;
|
|
1350
|
+
const acsUrl = `${baseURL}/sso/saml2/sp/acs/${providerId}`;
|
|
1351
|
+
let metadata = spData?.metadata;
|
|
1352
|
+
if (!metadata) metadata = saml.SPMetadata({
|
|
1353
|
+
entityID: spData?.entityID || config.issuer,
|
|
1354
|
+
assertionConsumerService: [{
|
|
1355
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1356
|
+
Location: acsUrl
|
|
1357
|
+
}],
|
|
1358
|
+
singleLogoutService: opts?.sloOptions ? [{
|
|
1359
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1360
|
+
Location: sloLocation
|
|
1361
|
+
}, {
|
|
1362
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1363
|
+
Location: sloLocation
|
|
1364
|
+
}] : void 0,
|
|
1365
|
+
wantMessageSigned: config.wantAssertionsSigned || false,
|
|
1366
|
+
authnRequestsSigned: config.authnRequestsSigned || false,
|
|
1367
|
+
nameIDFormat: config.identifierFormat ? [config.identifierFormat] : void 0
|
|
1368
|
+
}).getMetadata() || "";
|
|
1369
|
+
return saml.ServiceProvider({
|
|
1370
|
+
metadata,
|
|
1371
|
+
allowCreate: true,
|
|
1372
|
+
wantLogoutRequestSigned: opts?.sloOptions?.wantLogoutRequestSigned ?? false,
|
|
1373
|
+
wantLogoutResponseSigned: opts?.sloOptions?.wantLogoutResponseSigned ?? false,
|
|
1374
|
+
privateKey: normalizePem(spData?.privateKey || config.privateKey),
|
|
1375
|
+
privateKeyPass: spData?.privateKeyPass,
|
|
1376
|
+
isAssertionEncrypted: spData?.isAssertionEncrypted || false,
|
|
1377
|
+
encPrivateKey: normalizePem(spData?.encPrivateKey),
|
|
1378
|
+
encPrivateKeyPass: spData?.encPrivateKeyPass,
|
|
1379
|
+
relayState: opts?.relayState,
|
|
1380
|
+
clockDrifts: opts?.clockSkew && opts?.clockSkew !== 0 ? [-opts.clockSkew, opts.clockSkew] : void 0
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
function assertSAMLIdentityProviderAuthority(config) {
|
|
1384
|
+
if (config.idpMetadata?.metadata || config.idpMetadata?.entityID) return;
|
|
1385
|
+
throw new APIError("BAD_REQUEST", { message: "SAML manual IdP configuration requires idpMetadata.entityID; issuer identifies the service provider and cannot identify the IdP" });
|
|
1386
|
+
}
|
|
1387
|
+
function createIdP(config) {
|
|
1388
|
+
assertSAMLIdentityProviderAuthority(config);
|
|
1389
|
+
const idpData = config.idpMetadata;
|
|
1390
|
+
if (idpData?.metadata) return saml.IdentityProvider({
|
|
1391
|
+
metadata: idpData.metadata,
|
|
1392
|
+
privateKey: normalizePem(idpData.privateKey),
|
|
1393
|
+
privateKeyPass: idpData.privateKeyPass,
|
|
1394
|
+
isAssertionEncrypted: idpData.isAssertionEncrypted,
|
|
1395
|
+
encPrivateKey: normalizePem(idpData.encPrivateKey),
|
|
1396
|
+
encPrivateKeyPass: idpData.encPrivateKeyPass
|
|
1397
|
+
});
|
|
1398
|
+
return saml.IdentityProvider({
|
|
1399
|
+
entityID: idpData.entityID,
|
|
1400
|
+
singleSignOnService: idpData.singleSignOnService || [{
|
|
1401
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1402
|
+
Location: config.entryPoint
|
|
1403
|
+
}],
|
|
1404
|
+
singleLogoutService: idpData.singleLogoutService,
|
|
1405
|
+
signingCert: normalizePemList(resolveSigningCerts(config)),
|
|
1406
|
+
wantAuthnRequestsSigned: config.authnRequestsSigned || false,
|
|
1407
|
+
isAssertionEncrypted: idpData.isAssertionEncrypted || false,
|
|
1408
|
+
encPrivateKey: normalizePem(idpData.encPrivateKey),
|
|
1409
|
+
encPrivateKeyPass: idpData.encPrivateKeyPass
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
function escapeHtml(str) {
|
|
1413
|
+
if (!str) return "";
|
|
1414
|
+
return String(str).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1415
|
+
}
|
|
1416
|
+
function isSAMLPostBindingLocation(value) {
|
|
1417
|
+
let url;
|
|
1418
|
+
try {
|
|
1419
|
+
url = new URL(value);
|
|
1420
|
+
} catch {
|
|
1421
|
+
return false;
|
|
1422
|
+
}
|
|
1423
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
1424
|
+
}
|
|
1425
|
+
function createSAMLPostForm(action, samlParam, samlValue, relayState) {
|
|
1426
|
+
if (!isSAMLPostBindingLocation(action)) throw new APIError("BAD_REQUEST", { message: "SAML POST binding location must be an absolute http or https URL" });
|
|
1427
|
+
const safeAction = escapeHtml(action);
|
|
1428
|
+
const safeSamlParam = escapeHtml(samlParam);
|
|
1429
|
+
const safeSamlValue = escapeHtml(samlValue);
|
|
1430
|
+
const safeRelayState = relayState ? escapeHtml(relayState) : void 0;
|
|
1431
|
+
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>`;
|
|
1432
|
+
return new Response(html, { headers: { "Content-Type": "text/html" } });
|
|
1433
|
+
}
|
|
1304
1434
|
//#endregion
|
|
1305
1435
|
//#region src/routes/schemas.ts
|
|
1436
|
+
const absoluteUrlSchema = z.url();
|
|
1437
|
+
const samlRedirectUrlSchema = z.string().refine((url) => isSafeSAMLRedirectPath(url) || absoluteUrlSchema.safeParse(url).success, { message: "Expected an absolute URL or a relative path starting with /" });
|
|
1306
1438
|
function getSSOProviderAdditionalFields$1(options) {
|
|
1307
1439
|
return options?.schema?.ssoProvider?.additionalFields ?? {};
|
|
1308
1440
|
}
|
|
@@ -1327,16 +1459,14 @@ function parseSSOProviderAdditionalFields(options, data, action) {
|
|
|
1327
1459
|
action
|
|
1328
1460
|
});
|
|
1329
1461
|
}
|
|
1330
|
-
const oidcMappingSchema = z.
|
|
1331
|
-
id: z.string().meta({ description: "Field mapping for user ID (defaults to 'sub')" }),
|
|
1462
|
+
const oidcMappingSchema = z.strictObject({
|
|
1332
1463
|
email: z.string().meta({ description: "Field mapping for email (defaults to 'email')" }),
|
|
1333
1464
|
emailVerified: z.string().meta({ description: "Field mapping for email verification (defaults to 'email_verified')" }).optional(),
|
|
1334
1465
|
name: z.string().meta({ description: "Field mapping for name (defaults to 'name')" }),
|
|
1335
1466
|
image: z.string().meta({ description: "Field mapping for image (defaults to 'picture')" }).optional(),
|
|
1336
1467
|
extraFields: z.record(z.string(), z.any()).optional()
|
|
1337
1468
|
}).optional();
|
|
1338
|
-
const samlMappingSchema = z.
|
|
1339
|
-
id: z.string().meta({ description: "Field mapping for user ID (defaults to 'nameID')" }),
|
|
1469
|
+
const samlMappingSchema = z.strictObject({
|
|
1340
1470
|
email: z.string().meta({ description: "Field mapping for email (defaults to 'email')" }),
|
|
1341
1471
|
emailVerified: z.string().meta({ description: "Field mapping for email verification" }).optional(),
|
|
1342
1472
|
name: z.string().meta({ description: "Field mapping for name (defaults to 'displayName')" }),
|
|
@@ -1366,29 +1496,40 @@ const oidcConfigSchema = z.object({
|
|
|
1366
1496
|
overrideUserInfo: z.boolean().optional(),
|
|
1367
1497
|
mapping: oidcMappingSchema
|
|
1368
1498
|
});
|
|
1499
|
+
const samlIdentityProviderConfigSchema = z.object({
|
|
1500
|
+
cert: signingCertSchema.meta({ description: "IdP signing certificate(s). Pass a single PEM string or an array for rolling rotation. Takes precedence over the top-level `cert`." }).optional(),
|
|
1501
|
+
privateKey: z.string().optional(),
|
|
1502
|
+
privateKeyPass: z.string().optional(),
|
|
1503
|
+
isAssertionEncrypted: z.boolean().optional(),
|
|
1504
|
+
encPrivateKey: z.string().optional(),
|
|
1505
|
+
encPrivateKeyPass: z.string().optional(),
|
|
1506
|
+
singleSignOnService: z.array(z.object({
|
|
1507
|
+
Binding: z.string().meta({ description: "The binding type for the SSO service" }),
|
|
1508
|
+
Location: z.string().url().meta({ description: "The URL for the SSO service" })
|
|
1509
|
+
})).meta({ description: "Single Sign-On service configuration" }).optional(),
|
|
1510
|
+
singleLogoutService: z.array(z.object({
|
|
1511
|
+
Binding: z.string(),
|
|
1512
|
+
Location: z.string().url()
|
|
1513
|
+
})).optional()
|
|
1514
|
+
});
|
|
1515
|
+
const samlIdentityProviderMetadataSchema = z.union([samlIdentityProviderConfigSchema.extend({
|
|
1516
|
+
metadata: z.string().min(1),
|
|
1517
|
+
entityID: z.string().optional()
|
|
1518
|
+
}), samlIdentityProviderConfigSchema.extend({
|
|
1519
|
+
metadata: z.undefined().optional(),
|
|
1520
|
+
entityID: z.string().min(1)
|
|
1521
|
+
})], "idpMetadata.entityID is required when IdP metadata XML is not provided");
|
|
1522
|
+
const samlIdentityProviderMetadataUpdateSchema = samlIdentityProviderConfigSchema.extend({
|
|
1523
|
+
metadata: z.string().min(1).optional(),
|
|
1524
|
+
entityID: z.string().min(1).optional()
|
|
1525
|
+
});
|
|
1369
1526
|
const samlConfigSchema = z.object({
|
|
1370
1527
|
entryPoint: z.string().url().meta({ description: "The IdP SSO URL (entry point)" }),
|
|
1371
1528
|
cert: signingCertSchema.meta({ description: "IdP signing certificate(s). Pass a single PEM string or an array for rolling rotation. Omit when `idpMetadata.metadata` XML carries the certs. When both this and `idpMetadata.cert` are set, `idpMetadata.cert` wins." }).optional(),
|
|
1372
1529
|
audience: z.string().optional(),
|
|
1373
1530
|
callbackUrl: z.string().refine((url) => !url.includes("#"), { message: "callbackUrl must not contain a fragment" }).optional(),
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
entityID: z.string().optional(),
|
|
1377
|
-
cert: signingCertSchema.meta({ description: "IdP signing certificate(s). Pass a single PEM string or an array for rolling rotation. Takes precedence over the top-level `cert`." }).optional(),
|
|
1378
|
-
privateKey: z.string().optional(),
|
|
1379
|
-
privateKeyPass: z.string().optional(),
|
|
1380
|
-
isAssertionEncrypted: z.boolean().optional(),
|
|
1381
|
-
encPrivateKey: z.string().optional(),
|
|
1382
|
-
encPrivateKeyPass: z.string().optional(),
|
|
1383
|
-
singleSignOnService: z.array(z.object({
|
|
1384
|
-
Binding: z.string().meta({ description: "The binding type for the SSO service" }),
|
|
1385
|
-
Location: z.string().url().meta({ description: "The URL for the SSO service" })
|
|
1386
|
-
})).meta({ description: "Single Sign-On service configuration" }).optional(),
|
|
1387
|
-
singleLogoutService: z.array(z.object({
|
|
1388
|
-
Binding: z.string(),
|
|
1389
|
-
Location: z.string().url()
|
|
1390
|
-
})).optional()
|
|
1391
|
-
}).optional(),
|
|
1531
|
+
idpInitiatedCallbackUrl: samlRedirectUrlSchema.optional(),
|
|
1532
|
+
idpMetadata: samlIdentityProviderMetadataSchema,
|
|
1392
1533
|
spMetadata: z.object({
|
|
1393
1534
|
metadata: z.string().optional(),
|
|
1394
1535
|
entityID: z.string().optional(),
|
|
@@ -1423,7 +1564,10 @@ const updateSSOProviderBodySchema = z.object({
|
|
|
1423
1564
|
issuer: z.string().url().optional(),
|
|
1424
1565
|
domain: z.string().optional(),
|
|
1425
1566
|
oidcConfig: oidcConfigSchema.partial().optional(),
|
|
1426
|
-
samlConfig: samlConfigSchema.partial().
|
|
1567
|
+
samlConfig: samlConfigSchema.omit({ idpMetadata: true }).partial().extend({
|
|
1568
|
+
idpInitiatedCallbackUrl: samlRedirectUrlSchema.nullable().optional(),
|
|
1569
|
+
idpMetadata: samlIdentityProviderMetadataUpdateSchema.optional()
|
|
1570
|
+
}).optional()
|
|
1427
1571
|
});
|
|
1428
1572
|
function getUpdateSSOProviderBodySchema(options) {
|
|
1429
1573
|
return updateSSOProviderBodySchema.extend({
|
|
@@ -1454,12 +1598,12 @@ const SAML_IDP_BOUNDARY_FIELDS = [
|
|
|
1454
1598
|
"singleSignOnService"
|
|
1455
1599
|
];
|
|
1456
1600
|
const SAML_SP_BOUNDARY_FIELDS = ["metadata", "entityID"];
|
|
1457
|
-
function isRecord(value) {
|
|
1601
|
+
function isRecord$2(value) {
|
|
1458
1602
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1459
1603
|
}
|
|
1460
1604
|
function stableStringify(value) {
|
|
1461
1605
|
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
1462
|
-
if (isRecord(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
|
|
1606
|
+
if (isRecord$2(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
|
|
1463
1607
|
return JSON.stringify(value) ?? String(value);
|
|
1464
1608
|
}
|
|
1465
1609
|
function identityValueChanged(current, updated) {
|
|
@@ -1469,10 +1613,10 @@ function hasChangedField(current, updated, fields) {
|
|
|
1469
1613
|
return fields.some((field) => identityValueChanged(current?.[field], updated?.[field]));
|
|
1470
1614
|
}
|
|
1471
1615
|
function oidcIdentityBoundaryChanged(current, updated) {
|
|
1472
|
-
return hasChangedField(current, updated, OIDC_IDENTITY_BOUNDARY_FIELDS)
|
|
1616
|
+
return hasChangedField(current, updated, OIDC_IDENTITY_BOUNDARY_FIELDS);
|
|
1473
1617
|
}
|
|
1474
1618
|
function samlIdentityBoundaryChanged(current, updated) {
|
|
1475
|
-
return hasChangedField(current, updated, SAML_IDENTITY_BOUNDARY_FIELDS) ||
|
|
1619
|
+
return hasChangedField(current, updated, SAML_IDENTITY_BOUNDARY_FIELDS) || hasChangedField(current.idpMetadata, updated.idpMetadata, SAML_IDP_BOUNDARY_FIELDS) || hasChangedField(current.spMetadata, updated.spMetadata, SAML_SP_BOUNDARY_FIELDS);
|
|
1476
1620
|
}
|
|
1477
1621
|
function parseConfigSnapshot(config, configType) {
|
|
1478
1622
|
if (!config) return;
|
|
@@ -1499,14 +1643,12 @@ async function lockSSOProviderRow(adapter, providerId) {
|
|
|
1499
1643
|
});
|
|
1500
1644
|
}
|
|
1501
1645
|
async function lockSSOProviderForAccountLink(ctx, provider) {
|
|
1646
|
+
if (typeof provider.id !== "string") return;
|
|
1502
1647
|
const lockedProvider = await lockSSOProviderRow(ctx.context.adapter, provider.providerId);
|
|
1503
|
-
if (!lockedProvider) {
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
message: "SSO provider changed while account linking was in progress"
|
|
1508
|
-
});
|
|
1509
|
-
}
|
|
1648
|
+
if (!lockedProvider) throw new APIError("CONFLICT", {
|
|
1649
|
+
code: "SSO_PROVIDER_CHANGED",
|
|
1650
|
+
message: "SSO provider changed while account linking was in progress"
|
|
1651
|
+
});
|
|
1510
1652
|
if (ssoProviderIdentityBoundaryChanged(provider, lockedProvider)) throw new APIError("CONFLICT", {
|
|
1511
1653
|
code: "SSO_PROVIDER_CHANGED",
|
|
1512
1654
|
message: "SSO provider changed while account linking was in progress"
|
|
@@ -1607,6 +1749,8 @@ function sanitizeProvider(provider, baseURL, options) {
|
|
|
1607
1749
|
} : void 0,
|
|
1608
1750
|
samlConfig: samlConfig ? {
|
|
1609
1751
|
entryPoint: samlConfig.entryPoint,
|
|
1752
|
+
callbackUrl: samlConfig.callbackUrl,
|
|
1753
|
+
idpInitiatedCallbackUrl: samlConfig.idpInitiatedCallbackUrl,
|
|
1610
1754
|
audience: samlConfig.audience,
|
|
1611
1755
|
wantAssertionsSigned: samlConfig.wantAssertionsSigned,
|
|
1612
1756
|
authnRequestsSigned: samlConfig.authnRequestsSigned,
|
|
@@ -1696,6 +1840,15 @@ function parseAndValidateConfig(configString, configType) {
|
|
|
1696
1840
|
if (!config) throw new APIError("BAD_REQUEST", { message: `Cannot update ${configType} config for a provider that doesn't have ${configType} configured` });
|
|
1697
1841
|
return config;
|
|
1698
1842
|
}
|
|
1843
|
+
function mergeSAMLIdentityProviderMetadata(current, updates) {
|
|
1844
|
+
if (!updates) return current;
|
|
1845
|
+
const config = { idpMetadata: {
|
|
1846
|
+
...current,
|
|
1847
|
+
...updates
|
|
1848
|
+
} };
|
|
1849
|
+
assertSAMLIdentityProviderAuthority(config);
|
|
1850
|
+
return config.idpMetadata;
|
|
1851
|
+
}
|
|
1699
1852
|
function mergeSAMLConfig(current, updates, issuer) {
|
|
1700
1853
|
return {
|
|
1701
1854
|
...current,
|
|
@@ -1703,11 +1856,15 @@ function mergeSAMLConfig(current, updates, issuer) {
|
|
|
1703
1856
|
issuer,
|
|
1704
1857
|
entryPoint: updates.entryPoint ?? current.entryPoint,
|
|
1705
1858
|
cert: updates.cert ?? current.cert,
|
|
1706
|
-
spMetadata: updates.spMetadata
|
|
1707
|
-
|
|
1859
|
+
spMetadata: updates.spMetadata ? {
|
|
1860
|
+
...current.spMetadata,
|
|
1861
|
+
...updates.spMetadata
|
|
1862
|
+
} : current.spMetadata,
|
|
1863
|
+
idpMetadata: mergeSAMLIdentityProviderMetadata(current.idpMetadata, updates.idpMetadata),
|
|
1708
1864
|
mapping: updates.mapping ?? current.mapping,
|
|
1709
1865
|
audience: updates.audience ?? current.audience,
|
|
1710
1866
|
callbackUrl: updates.callbackUrl ?? current.callbackUrl,
|
|
1867
|
+
idpInitiatedCallbackUrl: updates.idpInitiatedCallbackUrl === null ? void 0 : updates.idpInitiatedCallbackUrl ?? current.idpInitiatedCallbackUrl,
|
|
1711
1868
|
wantAssertionsSigned: updates.wantAssertionsSigned ?? current.wantAssertionsSigned,
|
|
1712
1869
|
authnRequestsSigned: updates.authnRequestsSigned ?? current.authnRequestsSigned,
|
|
1713
1870
|
identifierFormat: updates.identifierFormat ?? current.identifierFormat,
|
|
@@ -1780,6 +1937,7 @@ const updateSSOProvider = (options) => {
|
|
|
1780
1937
|
const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
|
|
1781
1938
|
const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
|
|
1782
1939
|
validateCertSources(updatedSamlConfig);
|
|
1940
|
+
assertSAMLIdentityProviderAuthority(updatedSamlConfig);
|
|
1783
1941
|
if (samlIdentityBoundaryChanged(currentSamlConfig, updatedSamlConfig)) providerIdentityBoundaryChanged = true;
|
|
1784
1942
|
updateData.samlConfig = JSON.stringify(updatedSamlConfig);
|
|
1785
1943
|
}
|
|
@@ -1986,6 +2144,80 @@ const verifyDomain = (options) => {
|
|
|
1986
2144
|
});
|
|
1987
2145
|
};
|
|
1988
2146
|
//#endregion
|
|
2147
|
+
//#region src/provider-reference.ts
|
|
2148
|
+
const SSO_PROVIDER_STATE_KEY = "ssoProviderReference";
|
|
2149
|
+
function serializeCanonical(value) {
|
|
2150
|
+
if (value === null) return "null";
|
|
2151
|
+
if (Array.isArray(value)) return `[${value.map((entry) => entry === void 0 ? "null" : serializeCanonical(entry)).join(",")}]`;
|
|
2152
|
+
switch (typeof value) {
|
|
2153
|
+
case "boolean":
|
|
2154
|
+
case "number":
|
|
2155
|
+
case "string": return JSON.stringify(value);
|
|
2156
|
+
case "object": return `{${Object.entries(value).filter(([, entry]) => entry !== void 0).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([key, entry]) => `${JSON.stringify(key)}:${serializeCanonical(entry)}`).join(",")}}`;
|
|
2157
|
+
default: throw new TypeError("SSO provider configuration must be JSON-serializable");
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
function withoutOIDCSecret(configuration) {
|
|
2161
|
+
if (!configuration) return void 0;
|
|
2162
|
+
const result = { ...configuration };
|
|
2163
|
+
result.clientSecret = void 0;
|
|
2164
|
+
return result;
|
|
2165
|
+
}
|
|
2166
|
+
function getProviderSource(provider) {
|
|
2167
|
+
return typeof provider.id === "string" && provider.id.length > 0 ? {
|
|
2168
|
+
type: "persisted",
|
|
2169
|
+
recordId: provider.id
|
|
2170
|
+
} : { type: "configured" };
|
|
2171
|
+
}
|
|
2172
|
+
async function computeProviderAuthenticationFingerprint(provider) {
|
|
2173
|
+
const digest = await createHash("SHA-256").digest(serializeCanonical({
|
|
2174
|
+
domain: provider.domain,
|
|
2175
|
+
domainVerified: "domainVerified" in provider ? provider.domainVerified : void 0,
|
|
2176
|
+
issuer: provider.issuer,
|
|
2177
|
+
organizationId: provider.organizationId,
|
|
2178
|
+
oidcConfig: withoutOIDCSecret(provider.oidcConfig)
|
|
2179
|
+
}));
|
|
2180
|
+
return base64Url.encode(new Uint8Array(digest), { padding: false });
|
|
2181
|
+
}
|
|
2182
|
+
async function computeSSOProviderReference(provider) {
|
|
2183
|
+
return {
|
|
2184
|
+
providerId: provider.providerId,
|
|
2185
|
+
source: getProviderSource(provider),
|
|
2186
|
+
authenticationConfigurationFingerprint: await computeProviderAuthenticationFingerprint(provider)
|
|
2187
|
+
};
|
|
2188
|
+
}
|
|
2189
|
+
function isRecord$1(value) {
|
|
2190
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2191
|
+
}
|
|
2192
|
+
function parseSSOProviderReference(value) {
|
|
2193
|
+
if (!isRecord$1(value) || !isRecord$1(value.source)) return null;
|
|
2194
|
+
const providerId = value.providerId;
|
|
2195
|
+
const authenticationConfigurationFingerprint = value.authenticationConfigurationFingerprint;
|
|
2196
|
+
const source = value.source;
|
|
2197
|
+
if (typeof providerId !== "string" || providerId.length === 0 || typeof authenticationConfigurationFingerprint !== "string" || authenticationConfigurationFingerprint.length === 0) return null;
|
|
2198
|
+
if (source.type === "configured") return {
|
|
2199
|
+
providerId,
|
|
2200
|
+
source: { type: "configured" },
|
|
2201
|
+
authenticationConfigurationFingerprint
|
|
2202
|
+
};
|
|
2203
|
+
if (source.type === "persisted" && typeof source.recordId === "string" && source.recordId.length > 0) return {
|
|
2204
|
+
providerId,
|
|
2205
|
+
source: {
|
|
2206
|
+
type: "persisted",
|
|
2207
|
+
recordId: source.recordId
|
|
2208
|
+
},
|
|
2209
|
+
authenticationConfigurationFingerprint
|
|
2210
|
+
};
|
|
2211
|
+
return null;
|
|
2212
|
+
}
|
|
2213
|
+
async function isCurrentSSOProviderReference(provider, reference) {
|
|
2214
|
+
if (!reference || reference.providerId !== provider.providerId) return false;
|
|
2215
|
+
const source = getProviderSource(provider);
|
|
2216
|
+
if (source.type !== reference.source.type) return false;
|
|
2217
|
+
if (source.type === "persisted" && (reference.source.type !== "persisted" || source.recordId !== reference.source.recordId)) return false;
|
|
2218
|
+
return reference.authenticationConfigurationFingerprint === await computeProviderAuthenticationFingerprint(provider);
|
|
2219
|
+
}
|
|
2220
|
+
//#endregion
|
|
1989
2221
|
//#region src/saml-state.ts
|
|
1990
2222
|
async function generateRelayState(c, link) {
|
|
1991
2223
|
const callbackURL = c.body.callbackURL;
|
|
@@ -1996,6 +2228,9 @@ async function generateRelayState(c, link) {
|
|
|
1996
2228
|
errorURL: c.body.errorCallbackURL,
|
|
1997
2229
|
newUserURL: c.body.newUserCallbackURL,
|
|
1998
2230
|
link,
|
|
2231
|
+
/**
|
|
2232
|
+
* This is the actual expiry time of the state
|
|
2233
|
+
*/
|
|
1999
2234
|
expiresAt: Date.now() + 600 * 1e3,
|
|
2000
2235
|
requestSignUp: c.body.requestSignUp
|
|
2001
2236
|
};
|
|
@@ -2016,6 +2251,10 @@ async function parseRelayState(c) {
|
|
|
2016
2251
|
try {
|
|
2017
2252
|
parsedData = await parseGenericState(c, state, {
|
|
2018
2253
|
cookieName: "relay_state",
|
|
2254
|
+
/**
|
|
2255
|
+
* SAML ACS receives a POST from the IdP, which is typically cross-origin.
|
|
2256
|
+
* SameSite=Lax (default) cookies are not sent on cross-site POST requests.
|
|
2257
|
+
*/
|
|
2019
2258
|
skipStateCookieCheck: true
|
|
2020
2259
|
});
|
|
2021
2260
|
} catch (error) {
|
|
@@ -2028,121 +2267,80 @@ async function parseRelayState(c) {
|
|
|
2028
2267
|
if (!parsedData.errorURL) parsedData.errorURL = errorURL;
|
|
2029
2268
|
return parsedData;
|
|
2030
2269
|
}
|
|
2031
|
-
const saml = typeof samlifyNamespace.SPMetadata === "function" && typeof samlifyNamespace.setSchemaValidator === "function" ? samlifyNamespace : samlifyDefault ?? samlifyNamespace;
|
|
2032
2270
|
//#endregion
|
|
2033
|
-
//#region src/
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
*/
|
|
2038
|
-
function normalizePemList(certs) {
|
|
2039
|
-
if (!certs) return certs;
|
|
2040
|
-
return certs.map((pem) => normalizePem(pem) ?? pem);
|
|
2271
|
+
//#region src/user-resolution.ts
|
|
2272
|
+
const SSO_AUTHENTICATION_FAILURE = Symbol("SSO_AUTHENTICATION_FAILURE");
|
|
2273
|
+
function isRecord(value) {
|
|
2274
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2041
2275
|
}
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
const match = options.defaultSSO.find((p) => p.providerId === providerId);
|
|
2045
|
-
if (match) return {
|
|
2046
|
-
...match,
|
|
2047
|
-
userId: "default",
|
|
2048
|
-
issuer: match.samlConfig?.issuer || "",
|
|
2049
|
-
...options.domainVerification?.enabled ? { domainVerified: true } : {}
|
|
2050
|
-
};
|
|
2051
|
-
}
|
|
2052
|
-
const res = await adapter.findOne({
|
|
2053
|
-
model: "ssoProvider",
|
|
2054
|
-
where: [{
|
|
2055
|
-
field: "providerId",
|
|
2056
|
-
value: providerId
|
|
2057
|
-
}]
|
|
2058
|
-
});
|
|
2059
|
-
if (!res) return null;
|
|
2060
|
-
return {
|
|
2061
|
-
...res,
|
|
2062
|
-
samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
|
|
2063
|
-
};
|
|
2276
|
+
function isNonEmptyString(value) {
|
|
2277
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
2064
2278
|
}
|
|
2065
|
-
function
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
if (!metadata) metadata = saml.SPMetadata({
|
|
2071
|
-
entityID: spData?.entityID || config.issuer,
|
|
2072
|
-
assertionConsumerService: [{
|
|
2073
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
2074
|
-
Location: acsUrl
|
|
2075
|
-
}],
|
|
2076
|
-
singleLogoutService: opts?.sloOptions ? [{
|
|
2077
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
2078
|
-
Location: sloLocation
|
|
2079
|
-
}, {
|
|
2080
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
2081
|
-
Location: sloLocation
|
|
2082
|
-
}] : void 0,
|
|
2083
|
-
wantMessageSigned: config.wantAssertionsSigned || false,
|
|
2084
|
-
authnRequestsSigned: config.authnRequestsSigned || false,
|
|
2085
|
-
nameIDFormat: config.identifierFormat ? [config.identifierFormat] : void 0
|
|
2086
|
-
}).getMetadata() || "";
|
|
2087
|
-
return saml.ServiceProvider({
|
|
2088
|
-
metadata,
|
|
2089
|
-
allowCreate: true,
|
|
2090
|
-
wantLogoutRequestSigned: opts?.sloOptions?.wantLogoutRequestSigned ?? false,
|
|
2091
|
-
wantLogoutResponseSigned: opts?.sloOptions?.wantLogoutResponseSigned ?? false,
|
|
2092
|
-
privateKey: normalizePem(spData?.privateKey || config.privateKey),
|
|
2093
|
-
privateKeyPass: spData?.privateKeyPass,
|
|
2094
|
-
isAssertionEncrypted: spData?.isAssertionEncrypted || false,
|
|
2095
|
-
encPrivateKey: normalizePem(spData?.encPrivateKey),
|
|
2096
|
-
encPrivateKeyPass: spData?.encPrivateKeyPass,
|
|
2097
|
-
relayState: opts?.relayState,
|
|
2098
|
-
clockDrifts: opts?.clockSkew && opts?.clockSkew !== 0 ? [-opts.clockSkew, opts.clockSkew] : void 0
|
|
2099
|
-
});
|
|
2279
|
+
function isSSOUserResolution(value) {
|
|
2280
|
+
if (!isRecord(value)) return false;
|
|
2281
|
+
if (value.action === "continue") return true;
|
|
2282
|
+
if (value.action === "link") return isNonEmptyString(value.userId) && (value.profile === "preserve" || value.profile === "update");
|
|
2283
|
+
return value.action === "reject" && isNonEmptyString(value.code) && (value.message === void 0 || typeof value.message === "string");
|
|
2100
2284
|
}
|
|
2101
|
-
function
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
});
|
|
2111
|
-
return saml.IdentityProvider({
|
|
2112
|
-
entityID: idpData?.entityID || config.issuer,
|
|
2113
|
-
singleSignOnService: idpData?.singleSignOnService || [{
|
|
2114
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
2115
|
-
Location: config.entryPoint
|
|
2116
|
-
}],
|
|
2117
|
-
singleLogoutService: idpData?.singleLogoutService,
|
|
2118
|
-
signingCert: normalizePemList(resolveSigningCerts(config)),
|
|
2119
|
-
wantAuthnRequestsSigned: config.authnRequestsSigned || false,
|
|
2120
|
-
isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
|
|
2121
|
-
encPrivateKey: normalizePem(idpData?.encPrivateKey),
|
|
2122
|
-
encPrivateKeyPass: idpData?.encPrivateKeyPass
|
|
2285
|
+
function logFailure(logger, message, error) {
|
|
2286
|
+
try {
|
|
2287
|
+
logger.error(message, ...error === void 0 ? [] : [error]);
|
|
2288
|
+
} catch {}
|
|
2289
|
+
}
|
|
2290
|
+
function resolutionFailure() {
|
|
2291
|
+
return new APIError("INTERNAL_SERVER_ERROR", {
|
|
2292
|
+
code: "SSO_USER_RESOLUTION_FAILED",
|
|
2293
|
+
message: "Unable to resolve the SSO user"
|
|
2123
2294
|
});
|
|
2124
2295
|
}
|
|
2125
|
-
function
|
|
2126
|
-
|
|
2127
|
-
|
|
2296
|
+
async function resolveSSOUser(resolveUser, input, database, logger) {
|
|
2297
|
+
let resolution;
|
|
2298
|
+
try {
|
|
2299
|
+
resolution = await resolveUser(input, { database });
|
|
2300
|
+
} catch (error) {
|
|
2301
|
+
logFailure(logger, "SSO user resolution failed", error);
|
|
2302
|
+
throw resolutionFailure();
|
|
2303
|
+
}
|
|
2304
|
+
if (!isSSOUserResolution(resolution)) {
|
|
2305
|
+
logFailure(logger, "SSO user resolver returned an invalid decision");
|
|
2306
|
+
throw resolutionFailure();
|
|
2307
|
+
}
|
|
2308
|
+
return resolution;
|
|
2128
2309
|
}
|
|
2129
|
-
function
|
|
2130
|
-
|
|
2310
|
+
function assertSSOUserResolutionNativeTransactionSupport(adapter) {
|
|
2311
|
+
if (typeof adapter.options?.adapterConfig.transaction === "function") return;
|
|
2312
|
+
throw new APIError("NOT_IMPLEMENTED", {
|
|
2313
|
+
code: "SSO_USER_RESOLUTION_REQUIRES_NATIVE_TRANSACTIONS",
|
|
2314
|
+
message: "SSO user resolution requires a database adapter with native transaction support"
|
|
2315
|
+
});
|
|
2316
|
+
}
|
|
2317
|
+
async function assertSSOUserResolutionAsyncContextSupport(getStorage = getCurrentDBAdapterAsyncLocalStorage) {
|
|
2131
2318
|
try {
|
|
2132
|
-
|
|
2319
|
+
await getStorage();
|
|
2133
2320
|
} catch {
|
|
2134
|
-
|
|
2321
|
+
throw new APIError("NOT_IMPLEMENTED", {
|
|
2322
|
+
code: "SSO_USER_RESOLUTION_REQUIRES_ASYNC_CONTEXT",
|
|
2323
|
+
message: "SSO user resolution requires database transaction async context support"
|
|
2324
|
+
});
|
|
2135
2325
|
}
|
|
2136
|
-
return url.protocol === "http:" || url.protocol === "https:";
|
|
2137
2326
|
}
|
|
2138
|
-
function
|
|
2139
|
-
if (!
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2327
|
+
function assertSSOUserResolutionSessionStorage(options) {
|
|
2328
|
+
if (!options.secondaryStorage || options.session?.storeSessionInDatabase === true && options.session.preserveSessionInDatabase !== true) return;
|
|
2329
|
+
throw new APIError("NOT_IMPLEMENTED", {
|
|
2330
|
+
code: "SSO_USER_RESOLUTION_REQUIRES_DATABASE_SESSIONS",
|
|
2331
|
+
message: "SSO user resolution requires database-backed sessions with database fallback"
|
|
2332
|
+
});
|
|
2333
|
+
}
|
|
2334
|
+
function requireSuccessfulSSOAuthentication(result) {
|
|
2335
|
+
if (!result.error) return result;
|
|
2336
|
+
throw Object.assign(/* @__PURE__ */ new Error("SSO authentication failed"), {
|
|
2337
|
+
[SSO_AUTHENTICATION_FAILURE]: true,
|
|
2338
|
+
result
|
|
2339
|
+
});
|
|
2340
|
+
}
|
|
2341
|
+
function getFailedSSOAuthenticationResult(error) {
|
|
2342
|
+
if (!(error instanceof Error) || !(SSO_AUTHENTICATION_FAILURE in error) || error[SSO_AUTHENTICATION_FAILURE] !== true || !("result" in error)) return;
|
|
2343
|
+
return error.result;
|
|
2146
2344
|
}
|
|
2147
2345
|
//#endregion
|
|
2148
2346
|
//#region src/saml/timestamp.ts
|
|
@@ -2187,42 +2385,43 @@ function validateSAMLTimestamp(conditions, options = {}) {
|
|
|
2187
2385
|
}
|
|
2188
2386
|
//#endregion
|
|
2189
2387
|
//#region src/routes/saml-pipeline.ts
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
* - Falls back to appOrigin if URL is invalid or unsafe
|
|
2195
|
-
*/
|
|
2196
|
-
function getSafeRedirectUrl(url, callbackPath, appOrigin, isTrustedOrigin) {
|
|
2197
|
-
if (!url) return appOrigin;
|
|
2198
|
-
if (url.startsWith("/") && !url.startsWith("//")) {
|
|
2388
|
+
function getSafeRedirectCandidate(url, callbackPathname, appOrigin, isTrustedOrigin) {
|
|
2389
|
+
if (!url) return;
|
|
2390
|
+
if (url.startsWith("/")) {
|
|
2391
|
+
if (!isSafeSAMLRedirectPath(url)) return;
|
|
2199
2392
|
try {
|
|
2200
2393
|
const absoluteUrl = new URL(url, appOrigin);
|
|
2201
|
-
if (absoluteUrl.origin !== appOrigin) return
|
|
2202
|
-
|
|
2203
|
-
if (absoluteUrl.pathname === callbackPathname) return appOrigin;
|
|
2394
|
+
if (absoluteUrl.origin !== appOrigin || absoluteUrl.pathname === callbackPathname) return;
|
|
2395
|
+
return url;
|
|
2204
2396
|
} catch {
|
|
2205
|
-
return
|
|
2397
|
+
return;
|
|
2206
2398
|
}
|
|
2207
|
-
return url;
|
|
2208
2399
|
}
|
|
2400
|
+
let absoluteUrl;
|
|
2209
2401
|
try {
|
|
2210
|
-
|
|
2211
|
-
if (absoluteUrl.origin === appOrigin) {
|
|
2212
|
-
const callbackPathname = new URL(callbackPath).pathname;
|
|
2213
|
-
if (absoluteUrl.pathname === callbackPathname) return appOrigin;
|
|
2214
|
-
return url;
|
|
2215
|
-
}
|
|
2216
|
-
} catch {}
|
|
2217
|
-
if (!isTrustedOrigin(url, { allowRelativePaths: false })) return appOrigin;
|
|
2218
|
-
try {
|
|
2219
|
-
const callbackPathname = new URL(callbackPath).pathname;
|
|
2220
|
-
if (new URL(url).pathname === callbackPathname) return appOrigin;
|
|
2402
|
+
absoluteUrl = new URL(url);
|
|
2221
2403
|
} catch {
|
|
2222
|
-
|
|
2404
|
+
return;
|
|
2223
2405
|
}
|
|
2406
|
+
if (absoluteUrl.origin !== appOrigin && !isTrustedOrigin(url, { allowRelativePaths: false })) return;
|
|
2407
|
+
if (absoluteUrl.origin === appOrigin && absoluteUrl.pathname === callbackPathname) return;
|
|
2224
2408
|
return url;
|
|
2225
2409
|
}
|
|
2410
|
+
/**
|
|
2411
|
+
* Returns the first safe redirect URL from an ordered list of candidates.
|
|
2412
|
+
* - Prevents open redirect attacks by validating against trusted origins
|
|
2413
|
+
* - Prevents redirect loops by checking if URL points to callback route
|
|
2414
|
+
* - Tries the next candidate when a URL is invalid or unsafe
|
|
2415
|
+
* - Falls back to appOrigin when no candidate is safe
|
|
2416
|
+
*/
|
|
2417
|
+
function getSafeRedirectUrl(candidates, callbackPath, appOrigin, isTrustedOrigin) {
|
|
2418
|
+
const callbackPathname = new URL(callbackPath).pathname;
|
|
2419
|
+
for (const candidate of candidates) {
|
|
2420
|
+
const safeCandidate = getSafeRedirectCandidate(candidate, callbackPathname, appOrigin, isTrustedOrigin);
|
|
2421
|
+
if (safeCandidate) return safeCandidate;
|
|
2422
|
+
}
|
|
2423
|
+
return appOrigin;
|
|
2424
|
+
}
|
|
2226
2425
|
function buildSAMLRedirectUrl(url, params) {
|
|
2227
2426
|
const searchParams = new URLSearchParams(params);
|
|
2228
2427
|
try {
|
|
@@ -2238,6 +2437,14 @@ function buildSAMLRedirectUrl(url, params) {
|
|
|
2238
2437
|
return `${urlWithoutFragment}${urlWithoutFragment.includes("?") ? "&" : "?"}${searchParams.toString()}${fragment}`;
|
|
2239
2438
|
}
|
|
2240
2439
|
}
|
|
2440
|
+
function getSAMLRedirectCandidates(relayStateCallbackUrl, samlConfig, samlOptions) {
|
|
2441
|
+
return [
|
|
2442
|
+
relayStateCallbackUrl,
|
|
2443
|
+
samlConfig?.idpInitiatedCallbackUrl,
|
|
2444
|
+
samlOptions?.idpInitiatedCallbackUrl,
|
|
2445
|
+
samlConfig?.callbackUrl
|
|
2446
|
+
];
|
|
2447
|
+
}
|
|
2241
2448
|
function toArray(value) {
|
|
2242
2449
|
if (Array.isArray(value)) return value;
|
|
2243
2450
|
return value ? [value] : [];
|
|
@@ -2304,7 +2511,10 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2304
2511
|
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
2305
2512
|
const sp = createSP(parsedSamlConfig, ctx.context.baseURL, providerId, { clockSkew: options?.saml?.clockSkew });
|
|
2306
2513
|
const idp = createIdP(parsedSamlConfig);
|
|
2307
|
-
const
|
|
2514
|
+
const redirectCandidates = getSAMLRedirectCandidates(relayState?.callbackURL, parsedSamlConfig, options?.saml);
|
|
2515
|
+
const samlRedirectUrl = getSafeRedirectUrl(redirectCandidates, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
2516
|
+
const samlErrorRedirectUrl = getSafeRedirectUrl([relayState?.errorURL, samlRedirectUrl], currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
2517
|
+
params.onErrorRedirectResolved?.(samlErrorRedirectUrl);
|
|
2308
2518
|
validateSingleAssertion(SAMLResponse);
|
|
2309
2519
|
let parsedResponse;
|
|
2310
2520
|
try {
|
|
@@ -2349,7 +2559,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2349
2559
|
expectedAudiences: expectedAudiences.filter(Boolean),
|
|
2350
2560
|
expectedRecipients: expectedRecipients.filter(Boolean)
|
|
2351
2561
|
});
|
|
2352
|
-
throw ctx.redirect(buildSAMLRedirectUrl(
|
|
2562
|
+
throw ctx.redirect(buildSAMLRedirectUrl(samlErrorRedirectUrl, {
|
|
2353
2563
|
error: "invalid_saml_response",
|
|
2354
2564
|
error_description: error.body?.message || error.message || "Invalid SAML response"
|
|
2355
2565
|
}));
|
|
@@ -2360,7 +2570,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2360
2570
|
expectedAudiences: expectedAudiences.filter(Boolean),
|
|
2361
2571
|
expectedRecipients: expectedRecipients.filter(Boolean)
|
|
2362
2572
|
});
|
|
2363
|
-
throw ctx.redirect(buildSAMLRedirectUrl(
|
|
2573
|
+
throw ctx.redirect(buildSAMLRedirectUrl(samlErrorRedirectUrl, {
|
|
2364
2574
|
error: "invalid_saml_response",
|
|
2365
2575
|
error_description: "SAML response binding could not be validated"
|
|
2366
2576
|
}));
|
|
@@ -2372,17 +2582,17 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2372
2582
|
enableInResponseToValidation: options?.saml?.enableInResponseToValidation,
|
|
2373
2583
|
allowIdpInitiated: options?.saml?.allowIdpInitiated
|
|
2374
2584
|
},
|
|
2375
|
-
redirectUrl:
|
|
2585
|
+
redirectUrl: samlErrorRedirectUrl
|
|
2376
2586
|
});
|
|
2377
2587
|
validateAudience(ctx, {
|
|
2378
2588
|
extract,
|
|
2379
2589
|
expectedAudience: parsedSamlConfig.audience || sp.entityMeta.getEntityID(),
|
|
2380
2590
|
providerId,
|
|
2381
|
-
redirectUrl:
|
|
2591
|
+
redirectUrl: samlErrorRedirectUrl
|
|
2382
2592
|
});
|
|
2593
|
+
const issuer = idp.entityMeta.getEntityID();
|
|
2383
2594
|
const assertionId = extractAssertionId(samlBindingContent);
|
|
2384
2595
|
if (assertionId) {
|
|
2385
|
-
const issuer = idp.entityMeta.getEntityID();
|
|
2386
2596
|
const conditions = extract.conditions;
|
|
2387
2597
|
const clockSkew = options?.saml?.clockSkew ?? 3e5;
|
|
2388
2598
|
const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
|
|
@@ -2402,7 +2612,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2402
2612
|
issuer,
|
|
2403
2613
|
providerId
|
|
2404
2614
|
});
|
|
2405
|
-
throw ctx.redirect(buildSAMLRedirectUrl(
|
|
2615
|
+
throw ctx.redirect(buildSAMLRedirectUrl(samlErrorRedirectUrl, {
|
|
2406
2616
|
error: "replay_detected",
|
|
2407
2617
|
error_description: "SAML assertion has already been used"
|
|
2408
2618
|
}));
|
|
@@ -2416,7 +2626,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2416
2626
|
};
|
|
2417
2627
|
const userInfo = {
|
|
2418
2628
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
|
|
2419
|
-
id:
|
|
2629
|
+
id: extract.nameID,
|
|
2420
2630
|
email: (attr(mapping.email || "email") || extract.nameID || "").toLowerCase(),
|
|
2421
2631
|
name: [attr(mapping.firstName || "givenName"), attr(mapping.lastName || "surname")].filter(Boolean).join(" ") || attr(mapping.name || "displayName") || extract.nameID,
|
|
2422
2632
|
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? parseProviderEmailVerified(attr(mapping.emailVerified)) : false
|
|
@@ -2431,8 +2641,8 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2431
2641
|
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
2432
2642
|
}
|
|
2433
2643
|
const isTrustedProvider = "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
2434
|
-
const callbackUrl =
|
|
2435
|
-
const errorUrl =
|
|
2644
|
+
const callbackUrl = redirectCandidates.some(Boolean) ? samlRedirectUrl : ctx.context.baseURL;
|
|
2645
|
+
const errorUrl = samlErrorRedirectUrl;
|
|
2436
2646
|
let result;
|
|
2437
2647
|
try {
|
|
2438
2648
|
result = await runWithTransaction(ctx.context.adapter, async () => {
|
|
@@ -2446,7 +2656,8 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2446
2656
|
},
|
|
2447
2657
|
account: {
|
|
2448
2658
|
providerId,
|
|
2449
|
-
|
|
2659
|
+
issuer,
|
|
2660
|
+
providerAccountId: userInfo.id,
|
|
2450
2661
|
accessToken: "",
|
|
2451
2662
|
refreshToken: ""
|
|
2452
2663
|
},
|
|
@@ -2482,7 +2693,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2482
2693
|
profile: {
|
|
2483
2694
|
providerType: "saml",
|
|
2484
2695
|
providerId,
|
|
2485
|
-
|
|
2696
|
+
providerAccountId: userInfo.id,
|
|
2486
2697
|
email: userInfo.email,
|
|
2487
2698
|
emailVerified: userInfo.emailVerified,
|
|
2488
2699
|
rawAttributes: attributes
|
|
@@ -2514,7 +2725,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2514
2725
|
expiresAt: session.expiresAt
|
|
2515
2726
|
}).catch((e) => ctx.context.logger.warn("Failed to create SAML session lookup record", e));
|
|
2516
2727
|
}
|
|
2517
|
-
return
|
|
2728
|
+
return samlRedirectUrl;
|
|
2518
2729
|
}
|
|
2519
2730
|
//#endregion
|
|
2520
2731
|
//#region src/routes/sso.ts
|
|
@@ -2660,10 +2871,6 @@ const registerSSOProvider = (options) => {
|
|
|
2660
2871
|
type: "object",
|
|
2661
2872
|
nullable: true,
|
|
2662
2873
|
properties: {
|
|
2663
|
-
id: {
|
|
2664
|
-
type: "string",
|
|
2665
|
-
description: "Field mapping for user ID (defaults to 'sub')"
|
|
2666
|
-
},
|
|
2667
2874
|
email: {
|
|
2668
2875
|
type: "string",
|
|
2669
2876
|
description: "Field mapping for email (defaults to 'email')"
|
|
@@ -2689,11 +2896,7 @@ const registerSSOProvider = (options) => {
|
|
|
2689
2896
|
description: "Additional field mappings"
|
|
2690
2897
|
}
|
|
2691
2898
|
},
|
|
2692
|
-
required: [
|
|
2693
|
-
"id",
|
|
2694
|
-
"email",
|
|
2695
|
-
"name"
|
|
2696
|
-
]
|
|
2899
|
+
required: ["email", "name"]
|
|
2697
2900
|
}
|
|
2698
2901
|
},
|
|
2699
2902
|
required: [
|
|
@@ -2768,13 +2971,13 @@ const registerSSOProvider = (options) => {
|
|
|
2768
2971
|
if (!member) throw new APIError("BAD_REQUEST", { message: "You are not a member of the organization" });
|
|
2769
2972
|
if (ctx.context.hasPlugin("organization") && !hasOrgAdminRole(member)) throw new APIError("FORBIDDEN", { message: "You must be an organization owner or admin to register SSO providers" });
|
|
2770
2973
|
}
|
|
2771
|
-
if (new Set([
|
|
2974
|
+
if ((/* @__PURE__ */ new Set([
|
|
2772
2975
|
...BUILT_IN_ACCOUNT_PROVIDER_IDS,
|
|
2773
2976
|
...Object.keys(ctx.context.options.socialProviders ?? {}),
|
|
2774
2977
|
...ctx.context.socialProviders.map((p) => p.id),
|
|
2775
2978
|
...ctx.context.trustedProviders,
|
|
2776
2979
|
...options?.defaultSSO?.map((p) => p.providerId) ?? []
|
|
2777
|
-
]).has(body.providerId)) {
|
|
2980
|
+
])).has(body.providerId)) {
|
|
2778
2981
|
ctx.context.logger.warn(`SSO provider registration rejected for reserved providerId: ${body.providerId}`);
|
|
2779
2982
|
throw new APIError("UNPROCESSABLE_ENTITY", { message: "This providerId is reserved and cannot be used for an SSO provider" });
|
|
2780
2983
|
}
|
|
@@ -2868,6 +3071,7 @@ const registerSSOProvider = (options) => {
|
|
|
2868
3071
|
digestAlgorithm: body.samlConfig.digestAlgorithm
|
|
2869
3072
|
}, options?.saml?.algorithms);
|
|
2870
3073
|
validateCertSources(body.samlConfig);
|
|
3074
|
+
assertSAMLIdentityProviderAuthority(body.samlConfig);
|
|
2871
3075
|
const hasIdpMetadata = body.samlConfig.idpMetadata?.metadata;
|
|
2872
3076
|
let hasEntryPoint = false;
|
|
2873
3077
|
if (body.samlConfig.entryPoint) try {
|
|
@@ -2899,6 +3103,7 @@ const registerSSOProvider = (options) => {
|
|
|
2899
3103
|
cert: body.samlConfig.cert,
|
|
2900
3104
|
audience: body.samlConfig.audience,
|
|
2901
3105
|
callbackUrl: body.samlConfig.callbackUrl,
|
|
3106
|
+
idpInitiatedCallbackUrl: body.samlConfig.idpInitiatedCallbackUrl,
|
|
2902
3107
|
idpMetadata: body.samlConfig.idpMetadata,
|
|
2903
3108
|
spMetadata: body.samlConfig.spMetadata,
|
|
2904
3109
|
wantAssertionsSigned: body.samlConfig.wantAssertionsSigned,
|
|
@@ -3107,7 +3312,7 @@ const signInSSO = (options) => {
|
|
|
3107
3312
|
throw error;
|
|
3108
3313
|
}
|
|
3109
3314
|
if (!config.authorizationEndpoint) throw new APIError("BAD_REQUEST", { message: "Invalid OIDC configuration. Authorization URL not found." });
|
|
3110
|
-
|
|
3315
|
+
await addOAuthServerContext({ [SSO_PROVIDER_STATE_KEY]: await computeSSOProviderReference(provider) });
|
|
3111
3316
|
const state = await generateState(ctx);
|
|
3112
3317
|
const redirectURI = getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options);
|
|
3113
3318
|
const authorizationURL = await createAuthorizationURL({
|
|
@@ -3196,13 +3401,14 @@ function getOIDCErrorDescription(error, fallback) {
|
|
|
3196
3401
|
* @param stateData - Pre-parsed state data. If not provided, it will be
|
|
3197
3402
|
* parsed from the request context.
|
|
3198
3403
|
*/
|
|
3199
|
-
async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
3404
|
+
async function handleOIDCCallback(ctx, options, providerId, stateData, parsedProviderReference) {
|
|
3200
3405
|
const { code, error, error_description } = ctx.query;
|
|
3201
3406
|
if (!stateData) stateData = await parseState(ctx);
|
|
3202
3407
|
if (!stateData) {
|
|
3203
3408
|
const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
|
|
3204
3409
|
throw ctx.redirect(`${errorURL}?error=invalid_state`);
|
|
3205
3410
|
}
|
|
3411
|
+
const providerReference = parsedProviderReference ?? parseSSOProviderReference(stateData.serverContext?.["ssoProviderReference"]);
|
|
3206
3412
|
const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
|
|
3207
3413
|
const redirectOIDCError = (error, description) => {
|
|
3208
3414
|
const baseURL = errorURL || callbackURL;
|
|
@@ -3216,6 +3422,7 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
3216
3422
|
if (!code || error) redirectOIDCError(error || "invalid_request", error_description || (error ? error : "authorization_code_not_found"));
|
|
3217
3423
|
const provider = await resolveOIDCProvider(ctx, options, providerId);
|
|
3218
3424
|
if (!provider) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
|
|
3425
|
+
if (!await isCurrentSSOProviderReference(provider, providerReference)) redirectOIDCError("invalid_state", "sso_provider_changed_during_authentication");
|
|
3219
3426
|
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
3220
3427
|
let config = provider.oidcConfig;
|
|
3221
3428
|
if (!config) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
|
|
@@ -3287,7 +3494,28 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
3287
3494
|
if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_response_not_found`);
|
|
3288
3495
|
let userInfo = null;
|
|
3289
3496
|
const mapping = config.mapping || {};
|
|
3497
|
+
const readStringClaim = (claims, claim) => {
|
|
3498
|
+
const value = claims[claim];
|
|
3499
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
3500
|
+
};
|
|
3290
3501
|
let rawProfile;
|
|
3502
|
+
let verifiedIdToken = null;
|
|
3503
|
+
if (tokenResponse.idToken) {
|
|
3504
|
+
const jwksEndpoint = config.jwksEndpoint;
|
|
3505
|
+
if (!jwksEndpoint) redirectOIDCError("invalid_provider", "jwks_endpoint_not_found");
|
|
3506
|
+
const verified = await validateOIDCIdToken(tokenResponse.idToken, jwksEndpoint, {
|
|
3507
|
+
audience: config.clientId,
|
|
3508
|
+
issuer: provider.issuer
|
|
3509
|
+
}, (url) => ctx.context.isTrustedOrigin(url)).catch((error) => {
|
|
3510
|
+
if (error instanceof DiscoveryError) redirectOIDCError("invalid_provider", error.message);
|
|
3511
|
+
ctx.context.logger.error(error);
|
|
3512
|
+
return null;
|
|
3513
|
+
});
|
|
3514
|
+
if (!verified) redirectOIDCError("invalid_provider", "token_not_verified");
|
|
3515
|
+
if (!readStringClaim(verified.payload, "sub")) redirectOIDCError("invalid_provider", "id_token_subject_missing");
|
|
3516
|
+
verifiedIdToken = verified;
|
|
3517
|
+
}
|
|
3518
|
+
if (options?.resolveUser && !verifiedIdToken) redirectOIDCError("invalid_provider", "id_token_required_for_user_resolution");
|
|
3291
3519
|
if (config.userInfoEndpoint) {
|
|
3292
3520
|
const userInfoResponse = await fetchOIDCEndpoint("userInfoEndpoint", config.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } }, (url) => ctx.context.isTrustedOrigin(url)).catch((e) => {
|
|
3293
3521
|
if (e instanceof DiscoveryError) redirectOIDCError("invalid_provider", e.message);
|
|
@@ -3295,58 +3523,83 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
3295
3523
|
});
|
|
3296
3524
|
if (userInfoResponse.error) redirectOIDCError("invalid_provider", userInfoResponse.error.message || userInfoResponse.error.statusText || "userinfo_response_error");
|
|
3297
3525
|
const rawUserInfo = userInfoResponse.data ?? redirectOIDCError("invalid_provider", "userinfo_response_not_found");
|
|
3526
|
+
if (verifiedIdToken && rawUserInfo.sub !== verifiedIdToken.payload.sub) redirectOIDCError("invalid_provider", "id_token_userinfo_subject_mismatch");
|
|
3298
3527
|
rawProfile = rawUserInfo;
|
|
3299
3528
|
userInfo = {
|
|
3300
3529
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, rawUserInfo[value]])),
|
|
3301
|
-
id: rawUserInfo
|
|
3302
|
-
email: rawUserInfo
|
|
3530
|
+
id: readStringClaim(rawUserInfo, "sub"),
|
|
3531
|
+
email: readStringClaim(rawUserInfo, mapping.email || "email"),
|
|
3303
3532
|
emailVerified: options?.trustEmailVerified ? parseProviderEmailVerified(rawUserInfo[mapping.emailVerified || "email_verified"]) : false,
|
|
3304
|
-
name: rawUserInfo
|
|
3305
|
-
image: rawUserInfo
|
|
3533
|
+
name: readStringClaim(rawUserInfo, mapping.name || "name"),
|
|
3534
|
+
image: readStringClaim(rawUserInfo, mapping.image || "picture")
|
|
3306
3535
|
};
|
|
3307
|
-
} else if (
|
|
3308
|
-
const idToken =
|
|
3536
|
+
} else if (verifiedIdToken) {
|
|
3537
|
+
const idToken = verifiedIdToken.payload;
|
|
3309
3538
|
rawProfile = idToken;
|
|
3310
|
-
if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=jwks_endpoint_not_found`);
|
|
3311
|
-
const verified = await validateOIDCIdToken(tokenResponse.idToken, config.jwksEndpoint, {
|
|
3312
|
-
audience: config.clientId,
|
|
3313
|
-
issuer: provider.issuer
|
|
3314
|
-
}, (url) => ctx.context.isTrustedOrigin(url)).catch((e) => {
|
|
3315
|
-
if (e instanceof DiscoveryError) redirectOIDCError("invalid_provider", e.message);
|
|
3316
|
-
ctx.context.logger.error(e);
|
|
3317
|
-
return null;
|
|
3318
|
-
});
|
|
3319
|
-
if (!verified) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_not_verified`);
|
|
3320
3539
|
userInfo = {
|
|
3321
|
-
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key,
|
|
3322
|
-
id: idToken
|
|
3323
|
-
email: idToken
|
|
3540
|
+
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, idToken[value]])),
|
|
3541
|
+
id: idToken.sub,
|
|
3542
|
+
email: readStringClaim(idToken, mapping.email || "email"),
|
|
3324
3543
|
emailVerified: options?.trustEmailVerified ? parseProviderEmailVerified(idToken[mapping.emailVerified || "email_verified"]) : false,
|
|
3325
|
-
name: idToken
|
|
3326
|
-
image: idToken
|
|
3544
|
+
name: readStringClaim(idToken, mapping.name || "name"),
|
|
3545
|
+
image: readStringClaim(idToken, mapping.image || "picture")
|
|
3327
3546
|
};
|
|
3328
3547
|
} else throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=user_info_endpoint_not_found`);
|
|
3329
3548
|
if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=missing_user_info`);
|
|
3330
3549
|
const userInfoEmail = userInfo.email;
|
|
3331
3550
|
const userInfoId = userInfo.id;
|
|
3551
|
+
const providerUser = {
|
|
3552
|
+
...Object.fromEntries(Object.entries(userInfo).filter(([key]) => key !== "id")),
|
|
3553
|
+
email: userInfoEmail,
|
|
3554
|
+
name: typeof userInfo.name === "string" ? userInfo.name : "",
|
|
3555
|
+
image: typeof userInfo.image === "string" ? userInfo.image : void 0,
|
|
3556
|
+
emailVerified: options?.trustEmailVerified ? userInfo.emailVerified === true : false
|
|
3557
|
+
};
|
|
3558
|
+
const accountKey = {
|
|
3559
|
+
issuer: verifiedIdToken && readStringClaim(verifiedIdToken.payload, "iss") || provider.issuer,
|
|
3560
|
+
providerAccountId: userInfoId
|
|
3561
|
+
};
|
|
3332
3562
|
const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfoEmail, provider.domain);
|
|
3333
3563
|
let linked;
|
|
3334
3564
|
try {
|
|
3565
|
+
if (options?.resolveUser) {
|
|
3566
|
+
assertSSOUserResolutionNativeTransactionSupport(ctx.context.adapter);
|
|
3567
|
+
assertSSOUserResolutionSessionStorage(ctx.context.options);
|
|
3568
|
+
await assertSSOUserResolutionAsyncContextSupport();
|
|
3569
|
+
}
|
|
3335
3570
|
linked = await runWithTransaction(ctx.context.adapter, async () => {
|
|
3336
3571
|
await lockSSOProviderForAccountLink(ctx, provider);
|
|
3337
|
-
|
|
3572
|
+
const currentProvider = await resolveOIDCProvider(ctx, options, providerId, await getCurrentAdapter(ctx.context.adapter));
|
|
3573
|
+
if (!currentProvider || !await isCurrentSSOProviderReference(currentProvider, providerReference)) throw new APIError("CONFLICT", {
|
|
3574
|
+
code: "SSO_PROVIDER_CHANGED",
|
|
3575
|
+
message: "SSO provider changed while account linking was in progress"
|
|
3576
|
+
});
|
|
3577
|
+
const resolution = options?.resolveUser ? await resolveSSOUser(options.resolveUser, {
|
|
3578
|
+
protocol: "oidc",
|
|
3579
|
+
providerId: provider.providerId,
|
|
3580
|
+
accountKey,
|
|
3581
|
+
providerUser,
|
|
3582
|
+
providerClaims: rawProfile ?? {}
|
|
3583
|
+
}, await getCurrentAdapter(ctx.context.adapter), ctx.context.logger) : void 0;
|
|
3584
|
+
if (resolution?.action === "reject") throw new APIError("FORBIDDEN", {
|
|
3585
|
+
code: resolution.code,
|
|
3586
|
+
...resolution.message === void 0 ? {} : { message: resolution.message }
|
|
3587
|
+
});
|
|
3588
|
+
const authentication = await handleOAuthUserInfo(ctx, {
|
|
3338
3589
|
userInfo: {
|
|
3339
|
-
|
|
3340
|
-
|
|
3590
|
+
...providerUser,
|
|
3591
|
+
email: providerUser.email,
|
|
3592
|
+
name: providerUser.name,
|
|
3341
3593
|
id: userInfoId,
|
|
3342
|
-
image:
|
|
3343
|
-
emailVerified:
|
|
3594
|
+
image: providerUser.image,
|
|
3595
|
+
emailVerified: providerUser.emailVerified
|
|
3344
3596
|
},
|
|
3345
3597
|
account: {
|
|
3346
3598
|
idToken: tokenResponse.idToken,
|
|
3347
3599
|
accessToken: tokenResponse.accessToken,
|
|
3348
3600
|
refreshToken: tokenResponse.refreshToken,
|
|
3349
|
-
|
|
3601
|
+
issuer: accountKey.issuer,
|
|
3602
|
+
providerAccountId: userInfoId,
|
|
3350
3603
|
providerId: provider.providerId,
|
|
3351
3604
|
accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
|
|
3352
3605
|
refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
|
|
@@ -3363,18 +3616,28 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
3363
3616
|
}
|
|
3364
3617
|
},
|
|
3365
3618
|
isTrustedProvider,
|
|
3366
|
-
trustProviderByName: false
|
|
3619
|
+
trustProviderByName: false,
|
|
3620
|
+
selectedUser: resolution?.action === "link" ? {
|
|
3621
|
+
userId: resolution.userId,
|
|
3622
|
+
profile: resolution.profile
|
|
3623
|
+
} : void 0,
|
|
3624
|
+
deferNonDatabaseWrites: !!options?.resolveUser,
|
|
3625
|
+
requireExactAccountBinding: !!options?.resolveUser
|
|
3367
3626
|
});
|
|
3368
|
-
|
|
3627
|
+
return options?.resolveUser ? requireSuccessfulSSOAuthentication(authentication) : authentication;
|
|
3628
|
+
}, { onAfterCommitHookError(error) {
|
|
3629
|
+
ctx.context.logger.error("Committed SSO authentication after-hook failed", error);
|
|
3630
|
+
} });
|
|
3369
3631
|
} catch (e) {
|
|
3370
|
-
|
|
3632
|
+
const failedAuthentication = getFailedSSOAuthenticationResult(e);
|
|
3633
|
+
if (failedAuthentication) linked = failedAuthentication;
|
|
3634
|
+
else if (isAPIError(e) && e.body?.code) {
|
|
3371
3635
|
const baseURL = errorURL || callbackURL;
|
|
3372
3636
|
const params = new URLSearchParams({ error: e.body.code });
|
|
3373
3637
|
if (e.body.message) params.set("error_description", e.body.message);
|
|
3374
3638
|
const sep = baseURL.includes("?") ? "&" : "?";
|
|
3375
3639
|
throw ctx.redirect(`${baseURL}${sep}${params.toString()}`);
|
|
3376
|
-
}
|
|
3377
|
-
throw e;
|
|
3640
|
+
} else throw e;
|
|
3378
3641
|
}
|
|
3379
3642
|
if (linked.error) {
|
|
3380
3643
|
const baseURL = errorURL || callbackURL;
|
|
@@ -3394,7 +3657,7 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
3394
3657
|
profile: {
|
|
3395
3658
|
providerType: "oidc",
|
|
3396
3659
|
providerId: provider.providerId,
|
|
3397
|
-
|
|
3660
|
+
providerAccountId: userInfoId,
|
|
3398
3661
|
email: userInfoEmail,
|
|
3399
3662
|
emailVerified: Boolean(userInfo.emailVerified),
|
|
3400
3663
|
rawAttributes: userInfo
|
|
@@ -3403,6 +3666,7 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
3403
3666
|
token: tokenResponse,
|
|
3404
3667
|
provisioningOptions: options?.organizationProvisioning
|
|
3405
3668
|
});
|
|
3669
|
+
if ("accountCookie" in linked && linked.accountCookie) await setAccountCookie(ctx, linked.accountCookie);
|
|
3406
3670
|
await setSessionCookie(ctx, {
|
|
3407
3671
|
session,
|
|
3408
3672
|
user
|
|
@@ -3434,7 +3698,7 @@ const callbackSSOEndpointConfig = {
|
|
|
3434
3698
|
* and falling back to the `ssoProvider` table. Returns `null` when no match is
|
|
3435
3699
|
* found so the caller can decide how to react (redirect, silently skip, etc.).
|
|
3436
3700
|
*/
|
|
3437
|
-
async function resolveOIDCProvider(ctx, options, providerId) {
|
|
3701
|
+
async function resolveOIDCProvider(ctx, options, providerId, adapter = ctx.context.adapter) {
|
|
3438
3702
|
const matchingDefault = options?.defaultSSO?.find((defaultProvider) => defaultProvider.providerId === providerId);
|
|
3439
3703
|
if (matchingDefault) return {
|
|
3440
3704
|
...matchingDefault,
|
|
@@ -3442,7 +3706,7 @@ async function resolveOIDCProvider(ctx, options, providerId) {
|
|
|
3442
3706
|
userId: "default",
|
|
3443
3707
|
...options?.domainVerification?.enabled ? { domainVerified: true } : {}
|
|
3444
3708
|
};
|
|
3445
|
-
return
|
|
3709
|
+
return adapter.findOne({
|
|
3446
3710
|
model: "ssoProvider",
|
|
3447
3711
|
where: [{
|
|
3448
3712
|
field: "providerId",
|
|
@@ -3482,7 +3746,7 @@ async function bounceIfIdpInitiated(ctx, options, providerId) {
|
|
|
3482
3746
|
});
|
|
3483
3747
|
return;
|
|
3484
3748
|
}
|
|
3485
|
-
|
|
3749
|
+
await addOAuthServerContext({ [SSO_PROVIDER_STATE_KEY]: await computeSSOProviderReference(provider) });
|
|
3486
3750
|
const state = await generateState(ctx);
|
|
3487
3751
|
const redirectURI = getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options);
|
|
3488
3752
|
const authorizationURL = await createAuthorizationURL({
|
|
@@ -3534,12 +3798,12 @@ const callbackSSOShared = (options) => {
|
|
|
3534
3798
|
const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
|
|
3535
3799
|
throw ctx.redirect(`${errorURL}?error=invalid_state`);
|
|
3536
3800
|
}
|
|
3537
|
-
const
|
|
3538
|
-
if (!
|
|
3801
|
+
const providerReference = parseSSOProviderReference(stateData.serverContext?.[SSO_PROVIDER_STATE_KEY]);
|
|
3802
|
+
if (!providerReference) {
|
|
3539
3803
|
const errorURL = stateData.errorURL || stateData.callbackURL;
|
|
3540
|
-
throw ctx.redirect(`${errorURL}?error=invalid_state&error_description=
|
|
3804
|
+
throw ctx.redirect(`${errorURL}?error=invalid_state&error_description=missing_sso_provider_reference`);
|
|
3541
3805
|
}
|
|
3542
|
-
return handleOIDCCallback(ctx, options, providerId, stateData);
|
|
3806
|
+
return handleOIDCCallback(ctx, options, providerReference.providerId, stateData, providerReference);
|
|
3543
3807
|
});
|
|
3544
3808
|
};
|
|
3545
3809
|
const acsEndpointBodySchema = z.object({
|
|
@@ -3569,13 +3833,14 @@ const acsEndpoint = (options) => {
|
|
|
3569
3833
|
const { providerId } = ctx.params;
|
|
3570
3834
|
const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`;
|
|
3571
3835
|
const appOrigin = new URL(ctx.context.baseURL).origin;
|
|
3836
|
+
let resolvedErrorRedirectUrl;
|
|
3572
3837
|
if (ctx.method === "GET" && !ctx.body?.SAMLResponse) {
|
|
3573
3838
|
if (!(await getSessionFromCtx(ctx))?.session) {
|
|
3574
3839
|
const errorURL = ctx.context.options.onAPIError?.errorURL || `${appOrigin}/error`;
|
|
3575
3840
|
throw ctx.redirect(`${errorURL}?error=invalid_request`);
|
|
3576
3841
|
}
|
|
3577
3842
|
const relayState = ctx.query?.RelayState;
|
|
3578
|
-
throw ctx.redirect(getSafeRedirectUrl(relayState, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings)));
|
|
3843
|
+
throw ctx.redirect(getSafeRedirectUrl([relayState], currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings)));
|
|
3579
3844
|
}
|
|
3580
3845
|
if (!ctx.body?.SAMLResponse) throw new APIError("BAD_REQUEST", { message: "SAMLResponse is required for POST requests" });
|
|
3581
3846
|
try {
|
|
@@ -3583,15 +3848,33 @@ const acsEndpoint = (options) => {
|
|
|
3583
3848
|
SAMLResponse: ctx.body.SAMLResponse,
|
|
3584
3849
|
RelayState: ctx.body.RelayState,
|
|
3585
3850
|
providerId,
|
|
3586
|
-
currentCallbackPath
|
|
3851
|
+
currentCallbackPath,
|
|
3852
|
+
onErrorRedirectResolved: (url) => {
|
|
3853
|
+
resolvedErrorRedirectUrl = url;
|
|
3854
|
+
}
|
|
3587
3855
|
}, options);
|
|
3588
3856
|
throw ctx.redirect(safeRedirectUrl);
|
|
3589
3857
|
} catch (error) {
|
|
3590
3858
|
if (error instanceof Response || error && typeof error === "object" && "status" in error && error.status === 302) throw error;
|
|
3591
3859
|
if (error instanceof APIError && error.statusCode === 400) {
|
|
3592
3860
|
const errorCode = (error.body?.code || "saml_error").toLowerCase();
|
|
3593
|
-
|
|
3594
|
-
|
|
3861
|
+
let redirectUrl = resolvedErrorRedirectUrl;
|
|
3862
|
+
if (!redirectUrl) {
|
|
3863
|
+
let parsedSamlConfig;
|
|
3864
|
+
try {
|
|
3865
|
+
parsedSamlConfig = (await findSAMLProvider(providerId, options, ctx.context.adapter))?.samlConfig;
|
|
3866
|
+
} catch (providerLookupError) {
|
|
3867
|
+
ctx.context.logger.warn("Failed to resolve SAML provider for error redirect", {
|
|
3868
|
+
providerId,
|
|
3869
|
+
error: providerLookupError
|
|
3870
|
+
});
|
|
3871
|
+
}
|
|
3872
|
+
redirectUrl = getSafeRedirectUrl(getSAMLRedirectCandidates(void 0, parsedSamlConfig, options?.saml), currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
3873
|
+
}
|
|
3874
|
+
throw ctx.redirect(buildSAMLRedirectUrl(redirectUrl, {
|
|
3875
|
+
error: errorCode,
|
|
3876
|
+
error_description: error.message
|
|
3877
|
+
}));
|
|
3595
3878
|
}
|
|
3596
3879
|
throw error;
|
|
3597
3880
|
}
|
|
@@ -3620,7 +3903,7 @@ const sloEndpoint = (options) => {
|
|
|
3620
3903
|
const samlResponse = ctx.body?.SAMLResponse || ctx.query?.SAMLResponse;
|
|
3621
3904
|
const relayState = ctx.body?.RelayState || ctx.query?.RelayState;
|
|
3622
3905
|
const appOrigin = new URL(ctx.context.baseURL).origin;
|
|
3623
|
-
const safeErrorURL = getSafeRedirectUrl(relayState, `${appOrigin}/sso/saml2/sp/slo/${providerId}`, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
3906
|
+
const safeErrorURL = getSafeRedirectUrl([relayState], `${appOrigin}/sso/saml2/sp/slo/${providerId}`, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
3624
3907
|
if (!samlRequest && !samlResponse) throw ctx.redirect(`${safeErrorURL}?error=invalid_request&error_description=missing_logout_data`);
|
|
3625
3908
|
const provider = await findSAMLProvider(providerId, options, ctx.context.adapter);
|
|
3626
3909
|
if (!provider?.samlConfig) throw APIError.from("NOT_FOUND", SAML_ERROR_CODES.SAML_PROVIDER_NOT_FOUND);
|
|
@@ -3660,7 +3943,7 @@ async function handleLogoutResponse(ctx, sp, idp, relayState, providerId) {
|
|
|
3660
3943
|
}
|
|
3661
3944
|
deleteSessionCookie(ctx);
|
|
3662
3945
|
const appOrigin = new URL(ctx.context.baseURL).origin;
|
|
3663
|
-
const safeRedirectUrl = getSafeRedirectUrl(relayState, `${appOrigin}/sso/saml2/sp/slo/${providerId}`, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
3946
|
+
const safeRedirectUrl = getSafeRedirectUrl([relayState], `${appOrigin}/sso/saml2/sp/slo/${providerId}`, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
3664
3947
|
throw ctx.redirect(safeRedirectUrl);
|
|
3665
3948
|
}
|
|
3666
3949
|
async function handleLogoutRequest(ctx, sp, idp, relayState, providerId) {
|