@better-auth/sso 1.5.0-beta.13 → 1.5.0-beta.16
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 +11 -11
- package/dist/client.d.mts +3 -2
- package/dist/client.mjs +1 -1
- package/dist/client.mjs.map +1 -1
- package/dist/{index-DCUy0gtM.d.mts → index-CbKvQr9M.d.mts} +129 -65
- package/dist/index.d.mts +56 -2
- package/dist/index.mjs +637 -238
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -8
- package/src/client.ts +1 -1
- package/src/constants.ts +21 -0
- package/src/domain-verification.test.ts +46 -5
- package/src/index.ts +43 -2
- package/src/oidc/discovery.test.ts +7 -12
- package/src/oidc.test.ts +302 -1
- package/src/providers.test.ts +39 -45
- package/src/routes/domain-verification.ts +34 -12
- package/src/routes/helpers.ts +126 -0
- package/src/routes/providers.ts +16 -14
- package/src/routes/sso.ts +932 -365
- package/src/saml/algorithms.test.ts +1 -9
- package/src/saml/error-codes.ts +11 -0
- package/src/saml.test.ts +736 -4
- package/src/types.ts +53 -2
- package/src/utils.test.ts +3 -0
- package/vitest.config.ts +6 -1
package/dist/index.mjs
CHANGED
|
@@ -8,10 +8,65 @@ import z from "zod/v4";
|
|
|
8
8
|
import { base64 } from "@better-auth/utils/base64";
|
|
9
9
|
import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
|
|
10
10
|
import { HIDE_METADATA, createAuthorizationURL, generateGenericState, generateState, parseGenericState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
|
|
11
|
-
import { setSessionCookie } from "better-auth/cookies";
|
|
11
|
+
import { deleteSessionCookie, setSessionCookie } from "better-auth/cookies";
|
|
12
12
|
import { handleOAuthUserInfo } from "better-auth/oauth2";
|
|
13
13
|
import { decodeJwt } from "jose";
|
|
14
|
+
import { defineErrorCodes } from "@better-auth/core/utils/error-codes";
|
|
14
15
|
|
|
16
|
+
//#region src/constants.ts
|
|
17
|
+
/**
|
|
18
|
+
* SAML Constants
|
|
19
|
+
*
|
|
20
|
+
* Centralized constants for SAML SSO functionality.
|
|
21
|
+
*/
|
|
22
|
+
/** Prefix for AuthnRequest IDs used in InResponseTo validation */
|
|
23
|
+
const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
|
|
24
|
+
/** Prefix for used Assertion IDs used in replay protection */
|
|
25
|
+
const USED_ASSERTION_KEY_PREFIX = "saml-used-assertion:";
|
|
26
|
+
/** Prefix for SAML session data (NameID + SessionIndex) for SLO */
|
|
27
|
+
const SAML_SESSION_KEY_PREFIX = "saml-session:";
|
|
28
|
+
/** Prefix for reverse lookup of SAML session by Better Auth session ID */
|
|
29
|
+
const SAML_SESSION_BY_ID_PREFIX = "saml-session-by-id:";
|
|
30
|
+
/** Prefix for LogoutRequest IDs used in SP-initiated SLO validation */
|
|
31
|
+
const LOGOUT_REQUEST_KEY_PREFIX = "saml-logout-request:";
|
|
32
|
+
/**
|
|
33
|
+
* Default TTL for AuthnRequest records (5 minutes).
|
|
34
|
+
* This should be sufficient for most IdPs while protecting against stale requests.
|
|
35
|
+
*/
|
|
36
|
+
const DEFAULT_AUTHN_REQUEST_TTL_MS = 300 * 1e3;
|
|
37
|
+
/**
|
|
38
|
+
* Default TTL for used assertion records (15 minutes).
|
|
39
|
+
* This should match the maximum expected NotOnOrAfter window plus clock skew.
|
|
40
|
+
*/
|
|
41
|
+
const DEFAULT_ASSERTION_TTL_MS = 900 * 1e3;
|
|
42
|
+
/**
|
|
43
|
+
* Default TTL for LogoutRequest records (5 minutes).
|
|
44
|
+
* Should be sufficient for IdP to process and respond.
|
|
45
|
+
*/
|
|
46
|
+
const DEFAULT_LOGOUT_REQUEST_TTL_MS = 300 * 1e3;
|
|
47
|
+
/**
|
|
48
|
+
* Default clock skew tolerance (5 minutes).
|
|
49
|
+
* Allows for minor time differences between IdP and SP servers.
|
|
50
|
+
*
|
|
51
|
+
* Accommodates:
|
|
52
|
+
* - Network latency and processing time
|
|
53
|
+
* - Clock synchronization differences (NTP drift)
|
|
54
|
+
* - Distributed systems across timezones
|
|
55
|
+
*/
|
|
56
|
+
const DEFAULT_CLOCK_SKEW_MS = 300 * 1e3;
|
|
57
|
+
/**
|
|
58
|
+
* Default maximum size for SAML responses (256 KB).
|
|
59
|
+
* Protects against memory exhaustion from oversized SAML payloads.
|
|
60
|
+
*/
|
|
61
|
+
const DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024;
|
|
62
|
+
/**
|
|
63
|
+
* Default maximum size for IdP metadata (100 KB).
|
|
64
|
+
* Protects against oversized metadata documents.
|
|
65
|
+
*/
|
|
66
|
+
const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
|
|
67
|
+
const SAML_STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success";
|
|
68
|
+
|
|
69
|
+
//#endregion
|
|
15
70
|
//#region src/utils.ts
|
|
16
71
|
/**
|
|
17
72
|
* Safely parses a value that might be a JSON string or already a parsed object.
|
|
@@ -161,7 +216,12 @@ async function assignOrganizationByDomain(ctx, options) {
|
|
|
161
216
|
|
|
162
217
|
//#endregion
|
|
163
218
|
//#region src/routes/domain-verification.ts
|
|
219
|
+
const DNS_LABEL_MAX_LENGTH = 63;
|
|
220
|
+
const DEFAULT_TOKEN_PREFIX = "better-auth-token";
|
|
164
221
|
const domainVerificationBodySchema = z$1.object({ providerId: z$1.string() });
|
|
222
|
+
function getVerificationIdentifier(options, providerId) {
|
|
223
|
+
return `_${options.domainVerification?.tokenPrefix || DEFAULT_TOKEN_PREFIX}-${providerId}`;
|
|
224
|
+
}
|
|
165
225
|
const requestDomainVerification = (options) => {
|
|
166
226
|
return createAuthEndpoint("/sso/request-domain-verification", {
|
|
167
227
|
method: "POST",
|
|
@@ -209,11 +269,12 @@ const requestDomainVerification = (options) => {
|
|
|
209
269
|
message: "Domain has already been verified",
|
|
210
270
|
code: "DOMAIN_VERIFIED"
|
|
211
271
|
});
|
|
272
|
+
const identifier = getVerificationIdentifier(options, provider.providerId);
|
|
212
273
|
const activeVerification = await ctx.context.adapter.findOne({
|
|
213
274
|
model: "verification",
|
|
214
275
|
where: [{
|
|
215
276
|
field: "identifier",
|
|
216
|
-
value:
|
|
277
|
+
value: identifier
|
|
217
278
|
}, {
|
|
218
279
|
field: "expiresAt",
|
|
219
280
|
value: /* @__PURE__ */ new Date(),
|
|
@@ -228,7 +289,7 @@ const requestDomainVerification = (options) => {
|
|
|
228
289
|
await ctx.context.adapter.create({
|
|
229
290
|
model: "verification",
|
|
230
291
|
data: {
|
|
231
|
-
identifier
|
|
292
|
+
identifier,
|
|
232
293
|
createdAt: /* @__PURE__ */ new Date(),
|
|
233
294
|
updatedAt: /* @__PURE__ */ new Date(),
|
|
234
295
|
value: domainVerificationToken,
|
|
@@ -287,11 +348,16 @@ const verifyDomain = (options) => {
|
|
|
287
348
|
message: "Domain has already been verified",
|
|
288
349
|
code: "DOMAIN_VERIFIED"
|
|
289
350
|
});
|
|
351
|
+
const identifier = getVerificationIdentifier(options, provider.providerId);
|
|
352
|
+
if (identifier.length > DNS_LABEL_MAX_LENGTH) throw new APIError("BAD_REQUEST", {
|
|
353
|
+
message: `Verification identifier exceeds the DNS label limit of ${DNS_LABEL_MAX_LENGTH} characters`,
|
|
354
|
+
code: "IDENTIFIER_TOO_LONG"
|
|
355
|
+
});
|
|
290
356
|
const activeVerification = await ctx.context.adapter.findOne({
|
|
291
357
|
model: "verification",
|
|
292
358
|
where: [{
|
|
293
359
|
field: "identifier",
|
|
294
|
-
value:
|
|
360
|
+
value: identifier
|
|
295
361
|
}, {
|
|
296
362
|
field: "expiresAt",
|
|
297
363
|
value: /* @__PURE__ */ new Date(),
|
|
@@ -314,7 +380,8 @@ const verifyDomain = (options) => {
|
|
|
314
380
|
});
|
|
315
381
|
}
|
|
316
382
|
try {
|
|
317
|
-
|
|
383
|
+
const hostname = new URL(provider.domain).hostname;
|
|
384
|
+
records = (await dns.resolveTxt(`${identifier}.${hostname}`)).flat();
|
|
318
385
|
} catch (error) {
|
|
319
386
|
ctx.context.logger.warn("DNS resolution failure while validating domain ownership", error);
|
|
320
387
|
}
|
|
@@ -334,48 +401,6 @@ const verifyDomain = (options) => {
|
|
|
334
401
|
});
|
|
335
402
|
};
|
|
336
403
|
|
|
337
|
-
//#endregion
|
|
338
|
-
//#region src/constants.ts
|
|
339
|
-
/**
|
|
340
|
-
* SAML Constants
|
|
341
|
-
*
|
|
342
|
-
* Centralized constants for SAML SSO functionality.
|
|
343
|
-
*/
|
|
344
|
-
/** Prefix for AuthnRequest IDs used in InResponseTo validation */
|
|
345
|
-
const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
|
|
346
|
-
/** Prefix for used Assertion IDs used in replay protection */
|
|
347
|
-
const USED_ASSERTION_KEY_PREFIX = "saml-used-assertion:";
|
|
348
|
-
/**
|
|
349
|
-
* Default TTL for AuthnRequest records (5 minutes).
|
|
350
|
-
* This should be sufficient for most IdPs while protecting against stale requests.
|
|
351
|
-
*/
|
|
352
|
-
const DEFAULT_AUTHN_REQUEST_TTL_MS = 300 * 1e3;
|
|
353
|
-
/**
|
|
354
|
-
* Default TTL for used assertion records (15 minutes).
|
|
355
|
-
* This should match the maximum expected NotOnOrAfter window plus clock skew.
|
|
356
|
-
*/
|
|
357
|
-
const DEFAULT_ASSERTION_TTL_MS = 900 * 1e3;
|
|
358
|
-
/**
|
|
359
|
-
* Default clock skew tolerance (5 minutes).
|
|
360
|
-
* Allows for minor time differences between IdP and SP servers.
|
|
361
|
-
*
|
|
362
|
-
* Accommodates:
|
|
363
|
-
* - Network latency and processing time
|
|
364
|
-
* - Clock synchronization differences (NTP drift)
|
|
365
|
-
* - Distributed systems across timezones
|
|
366
|
-
*/
|
|
367
|
-
const DEFAULT_CLOCK_SKEW_MS = 300 * 1e3;
|
|
368
|
-
/**
|
|
369
|
-
* Default maximum size for SAML responses (256 KB).
|
|
370
|
-
* Protects against memory exhaustion from oversized SAML payloads.
|
|
371
|
-
*/
|
|
372
|
-
const DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024;
|
|
373
|
-
/**
|
|
374
|
-
* Default maximum size for IdP metadata (100 KB).
|
|
375
|
-
* Protects against oversized metadata documents.
|
|
376
|
-
*/
|
|
377
|
-
const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
|
|
378
|
-
|
|
379
404
|
//#endregion
|
|
380
405
|
//#region src/saml/parser.ts
|
|
381
406
|
const xmlParser = new XMLParser({
|
|
@@ -829,7 +854,7 @@ const listSSOProviders = () => {
|
|
|
829
854
|
return ctx.json({ providers });
|
|
830
855
|
});
|
|
831
856
|
};
|
|
832
|
-
const
|
|
857
|
+
const getSSOProviderQuerySchema = z.object({ providerId: z.string() });
|
|
833
858
|
async function checkProviderAccess(ctx, providerId) {
|
|
834
859
|
const userId = ctx.context.session.user.id;
|
|
835
860
|
const provider = await ctx.context.adapter.findOne({
|
|
@@ -848,10 +873,10 @@ async function checkProviderAccess(ctx, providerId) {
|
|
|
848
873
|
return provider;
|
|
849
874
|
}
|
|
850
875
|
const getSSOProvider = () => {
|
|
851
|
-
return createAuthEndpoint("/sso/
|
|
876
|
+
return createAuthEndpoint("/sso/get-provider", {
|
|
852
877
|
method: "GET",
|
|
853
878
|
use: [sessionMiddleware],
|
|
854
|
-
|
|
879
|
+
query: getSSOProviderQuerySchema,
|
|
855
880
|
metadata: { openapi: {
|
|
856
881
|
operationId: "getSSOProvider",
|
|
857
882
|
summary: "Get SSO provider details",
|
|
@@ -863,7 +888,7 @@ const getSSOProvider = () => {
|
|
|
863
888
|
}
|
|
864
889
|
} }
|
|
865
890
|
}, async (ctx) => {
|
|
866
|
-
const { providerId } = ctx.
|
|
891
|
+
const { providerId } = ctx.query;
|
|
867
892
|
const provider = await checkProviderAccess(ctx, providerId);
|
|
868
893
|
return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
|
|
869
894
|
});
|
|
@@ -916,11 +941,10 @@ function mergeOIDCConfig(current, updates, issuer) {
|
|
|
916
941
|
};
|
|
917
942
|
}
|
|
918
943
|
const updateSSOProvider = (options) => {
|
|
919
|
-
return createAuthEndpoint("/sso/
|
|
920
|
-
method: "
|
|
944
|
+
return createAuthEndpoint("/sso/update-provider", {
|
|
945
|
+
method: "POST",
|
|
921
946
|
use: [sessionMiddleware],
|
|
922
|
-
|
|
923
|
-
body: updateSSOProviderBodySchema,
|
|
947
|
+
body: updateSSOProviderBodySchema.extend({ providerId: z.string() }),
|
|
924
948
|
metadata: { openapi: {
|
|
925
949
|
operationId: "updateSSOProvider",
|
|
926
950
|
summary: "Update SSO provider",
|
|
@@ -932,8 +956,7 @@ const updateSSOProvider = (options) => {
|
|
|
932
956
|
}
|
|
933
957
|
} }
|
|
934
958
|
}, async (ctx) => {
|
|
935
|
-
const { providerId } = ctx.
|
|
936
|
-
const body = ctx.body;
|
|
959
|
+
const { providerId, ...body } = ctx.body;
|
|
937
960
|
const { issuer, domain, samlConfig, oidcConfig } = body;
|
|
938
961
|
if (!issuer && !domain && !samlConfig && !oidcConfig) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
|
|
939
962
|
const existingProvider = await checkProviderAccess(ctx, providerId);
|
|
@@ -981,10 +1004,10 @@ const updateSSOProvider = (options) => {
|
|
|
981
1004
|
});
|
|
982
1005
|
};
|
|
983
1006
|
const deleteSSOProvider = () => {
|
|
984
|
-
return createAuthEndpoint("/sso/
|
|
985
|
-
method: "
|
|
1007
|
+
return createAuthEndpoint("/sso/delete-provider", {
|
|
1008
|
+
method: "POST",
|
|
986
1009
|
use: [sessionMiddleware],
|
|
987
|
-
|
|
1010
|
+
body: z.object({ providerId: z.string() }),
|
|
988
1011
|
metadata: { openapi: {
|
|
989
1012
|
operationId: "deleteSSOProvider",
|
|
990
1013
|
summary: "Delete SSO provider",
|
|
@@ -996,7 +1019,7 @@ const deleteSSOProvider = () => {
|
|
|
996
1019
|
}
|
|
997
1020
|
} }
|
|
998
1021
|
}, async (ctx) => {
|
|
999
|
-
const { providerId } = ctx.
|
|
1022
|
+
const { providerId } = ctx.body;
|
|
1000
1023
|
await checkProviderAccess(ctx, providerId);
|
|
1001
1024
|
await ctx.context.adapter.delete({
|
|
1002
1025
|
model: "ssoProvider",
|
|
@@ -1355,6 +1378,17 @@ function mapDiscoveryErrorToAPIError(error) {
|
|
|
1355
1378
|
}
|
|
1356
1379
|
}
|
|
1357
1380
|
|
|
1381
|
+
//#endregion
|
|
1382
|
+
//#region src/saml/error-codes.ts
|
|
1383
|
+
const SAML_ERROR_CODES = defineErrorCodes({
|
|
1384
|
+
SINGLE_LOGOUT_NOT_ENABLED: "Single Logout is not enabled",
|
|
1385
|
+
INVALID_LOGOUT_RESPONSE: "Invalid LogoutResponse",
|
|
1386
|
+
INVALID_LOGOUT_REQUEST: "Invalid LogoutRequest",
|
|
1387
|
+
LOGOUT_FAILED_AT_IDP: "Logout failed at IdP",
|
|
1388
|
+
IDP_SLO_NOT_SUPPORTED: "IdP does not support Single Logout Service",
|
|
1389
|
+
SAML_PROVIDER_NOT_FOUND: "SAML provider not found"
|
|
1390
|
+
});
|
|
1391
|
+
|
|
1358
1392
|
//#endregion
|
|
1359
1393
|
//#region src/saml-state.ts
|
|
1360
1394
|
async function generateRelayState(c, link, additionalData) {
|
|
@@ -1398,9 +1432,102 @@ async function parseRelayState(c) {
|
|
|
1398
1432
|
return parsedData;
|
|
1399
1433
|
}
|
|
1400
1434
|
|
|
1435
|
+
//#endregion
|
|
1436
|
+
//#region src/routes/helpers.ts
|
|
1437
|
+
async function findSAMLProvider(providerId, options, adapter) {
|
|
1438
|
+
if (options?.defaultSSO?.length) {
|
|
1439
|
+
const match = options.defaultSSO.find((p) => p.providerId === providerId);
|
|
1440
|
+
if (match) return {
|
|
1441
|
+
...match,
|
|
1442
|
+
userId: "default",
|
|
1443
|
+
issuer: match.samlConfig?.issuer || "",
|
|
1444
|
+
...options.domainVerification?.enabled ? { domainVerified: true } : {}
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
const res = await adapter.findOne({
|
|
1448
|
+
model: "ssoProvider",
|
|
1449
|
+
where: [{
|
|
1450
|
+
field: "providerId",
|
|
1451
|
+
value: providerId
|
|
1452
|
+
}]
|
|
1453
|
+
});
|
|
1454
|
+
if (!res) return null;
|
|
1455
|
+
return {
|
|
1456
|
+
...res,
|
|
1457
|
+
samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
function createSP(config, baseURL, providerId, sloOptions) {
|
|
1461
|
+
const sloLocation = `${baseURL}/sso/saml2/sp/slo/${providerId}`;
|
|
1462
|
+
return saml.ServiceProvider({
|
|
1463
|
+
entityID: config.spMetadata?.entityID || config.issuer,
|
|
1464
|
+
assertionConsumerService: [{
|
|
1465
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1466
|
+
Location: config.callbackUrl || `${baseURL}/sso/saml2/sp/acs/${providerId}`
|
|
1467
|
+
}],
|
|
1468
|
+
singleLogoutService: [{
|
|
1469
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1470
|
+
Location: sloLocation
|
|
1471
|
+
}, {
|
|
1472
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1473
|
+
Location: sloLocation
|
|
1474
|
+
}],
|
|
1475
|
+
wantMessageSigned: config.wantAssertionsSigned || false,
|
|
1476
|
+
wantLogoutRequestSigned: sloOptions?.wantLogoutRequestSigned ?? false,
|
|
1477
|
+
wantLogoutResponseSigned: sloOptions?.wantLogoutResponseSigned ?? false,
|
|
1478
|
+
metadata: config.spMetadata?.metadata,
|
|
1479
|
+
privateKey: config.spMetadata?.privateKey || config.privateKey,
|
|
1480
|
+
privateKeyPass: config.spMetadata?.privateKeyPass
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
function createIdP(config) {
|
|
1484
|
+
const idpData = config.idpMetadata;
|
|
1485
|
+
if (idpData?.metadata) return saml.IdentityProvider({
|
|
1486
|
+
metadata: idpData.metadata,
|
|
1487
|
+
privateKey: idpData.privateKey,
|
|
1488
|
+
privateKeyPass: idpData.privateKeyPass,
|
|
1489
|
+
encPrivateKey: idpData.encPrivateKey,
|
|
1490
|
+
encPrivateKeyPass: idpData.encPrivateKeyPass
|
|
1491
|
+
});
|
|
1492
|
+
return saml.IdentityProvider({
|
|
1493
|
+
entityID: idpData?.entityID || config.issuer,
|
|
1494
|
+
singleSignOnService: idpData?.singleSignOnService || [{
|
|
1495
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1496
|
+
Location: config.entryPoint
|
|
1497
|
+
}],
|
|
1498
|
+
singleLogoutService: idpData?.singleLogoutService,
|
|
1499
|
+
signingCert: idpData?.cert || config.cert
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
function escapeHtml(str) {
|
|
1503
|
+
if (!str) return "";
|
|
1504
|
+
return String(str).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1505
|
+
}
|
|
1506
|
+
function createSAMLPostForm(action, samlParam, samlValue, relayState) {
|
|
1507
|
+
const safeAction = escapeHtml(action);
|
|
1508
|
+
const safeSamlParam = escapeHtml(samlParam);
|
|
1509
|
+
const safeSamlValue = escapeHtml(samlValue);
|
|
1510
|
+
const safeRelayState = relayState ? escapeHtml(relayState) : void 0;
|
|
1511
|
+
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>`;
|
|
1512
|
+
return new Response(html, { headers: { "Content-Type": "text/html" } });
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1401
1515
|
//#endregion
|
|
1402
1516
|
//#region src/routes/sso.ts
|
|
1403
1517
|
/**
|
|
1518
|
+
* Builds the OIDC redirect URI. Uses the shared `redirectURI` option
|
|
1519
|
+
* when set, otherwise falls back to `/sso/callback/:providerId`.
|
|
1520
|
+
*/
|
|
1521
|
+
function getOIDCRedirectURI(baseURL, providerId, options) {
|
|
1522
|
+
if (options?.redirectURI?.trim()) try {
|
|
1523
|
+
new URL(options.redirectURI);
|
|
1524
|
+
return options.redirectURI;
|
|
1525
|
+
} catch {
|
|
1526
|
+
return `${baseURL}${options.redirectURI.startsWith("/") ? options.redirectURI : `/${options.redirectURI}`}`;
|
|
1527
|
+
}
|
|
1528
|
+
return `${baseURL}/sso/callback/${providerId}`;
|
|
1529
|
+
}
|
|
1530
|
+
/**
|
|
1404
1531
|
* Validates SAML assertion timestamp conditions (NotBefore/NotOnOrAfter).
|
|
1405
1532
|
* Prevents acceptance of expired or future-dated assertions.
|
|
1406
1533
|
* @throws {APIError} If timestamps are invalid, expired, or not yet valid
|
|
@@ -1464,7 +1591,7 @@ const spMetadataQuerySchema = z.object({
|
|
|
1464
1591
|
providerId: z.string(),
|
|
1465
1592
|
format: z.enum(["xml", "json"]).default("xml")
|
|
1466
1593
|
});
|
|
1467
|
-
const spMetadata = () => {
|
|
1594
|
+
const spMetadata = (options) => {
|
|
1468
1595
|
return createAuthEndpoint("/sso/saml2/sp/metadata", {
|
|
1469
1596
|
method: "GET",
|
|
1470
1597
|
query: spMetadataQuerySchema,
|
|
@@ -1485,12 +1612,21 @@ const spMetadata = () => {
|
|
|
1485
1612
|
if (!provider) throw new APIError("NOT_FOUND", { message: "No provider found for the given providerId" });
|
|
1486
1613
|
const parsedSamlConfig = safeJsonParse(provider.samlConfig);
|
|
1487
1614
|
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
1615
|
+
const sloLocation = `${ctx.context.baseURL}/sso/saml2/sp/slo/${ctx.query.providerId}`;
|
|
1616
|
+
const singleLogoutService = options?.saml?.enableSingleLogout ? [{
|
|
1617
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1618
|
+
Location: sloLocation
|
|
1619
|
+
}, {
|
|
1620
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1621
|
+
Location: sloLocation
|
|
1622
|
+
}] : void 0;
|
|
1488
1623
|
const sp = parsedSamlConfig.spMetadata.metadata ? saml.ServiceProvider({ metadata: parsedSamlConfig.spMetadata.metadata }) : saml.SPMetadata({
|
|
1489
1624
|
entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
1490
1625
|
assertionConsumerService: [{
|
|
1491
1626
|
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1492
1627
|
Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`
|
|
1493
1628
|
}],
|
|
1629
|
+
singleLogoutService,
|
|
1494
1630
|
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1495
1631
|
authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
|
|
1496
1632
|
nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
|
|
@@ -1878,7 +2014,7 @@ const registerSSOProvider = (options) => {
|
|
|
1878
2014
|
await ctx.context.adapter.create({
|
|
1879
2015
|
model: "verification",
|
|
1880
2016
|
data: {
|
|
1881
|
-
identifier: options
|
|
2017
|
+
identifier: getVerificationIdentifier(options, provider.providerId),
|
|
1882
2018
|
createdAt: /* @__PURE__ */ new Date(),
|
|
1883
2019
|
updatedAt: /* @__PURE__ */ new Date(),
|
|
1884
2020
|
value: domainVerificationToken,
|
|
@@ -1890,7 +2026,7 @@ const registerSSOProvider = (options) => {
|
|
|
1890
2026
|
...provider,
|
|
1891
2027
|
oidcConfig: safeJsonParse(provider.oidcConfig),
|
|
1892
2028
|
samlConfig: safeJsonParse(provider.samlConfig),
|
|
1893
|
-
redirectURI:
|
|
2029
|
+
redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
|
|
1894
2030
|
...options?.domainVerification?.enabled ? { domainVerified } : {},
|
|
1895
2031
|
...options?.domainVerification?.enabled ? { domainVerificationToken } : {}
|
|
1896
2032
|
};
|
|
@@ -2042,8 +2178,8 @@ const signInSSO = (options) => {
|
|
|
2042
2178
|
if (discovery.data) finalAuthUrl = discovery.data.authorization_endpoint;
|
|
2043
2179
|
}
|
|
2044
2180
|
if (!finalAuthUrl) throw new APIError("BAD_REQUEST", { message: "Invalid OIDC configuration. Authorization URL not found." });
|
|
2045
|
-
const state = await generateState(ctx, void 0, false);
|
|
2046
|
-
const redirectURI =
|
|
2181
|
+
const state = await generateState(ctx, void 0, options?.redirectURI?.trim() ? { ssoProviderId: provider.providerId } : false);
|
|
2182
|
+
const redirectURI = getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options);
|
|
2047
2183
|
const authorizationURL = await createAuthorizationURL({
|
|
2048
2184
|
id: provider.issuer,
|
|
2049
2185
|
options: {
|
|
@@ -2141,171 +2277,214 @@ const callbackSSOQuerySchema = z.object({
|
|
|
2141
2277
|
error: z.string().optional(),
|
|
2142
2278
|
error_description: z.string().optional()
|
|
2143
2279
|
});
|
|
2280
|
+
/**
|
|
2281
|
+
* Core OIDC callback handler logic, shared between the per-provider and
|
|
2282
|
+
* shared callback endpoints. Resolves the provider, exchanges the
|
|
2283
|
+
* authorization code for tokens, and creates a session.
|
|
2284
|
+
*
|
|
2285
|
+
* @param stateData - Pre-parsed state data. If not provided, it will be
|
|
2286
|
+
* parsed from the request context.
|
|
2287
|
+
*/
|
|
2288
|
+
async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
2289
|
+
const { code, error, error_description } = ctx.query;
|
|
2290
|
+
if (!stateData) stateData = await parseState(ctx);
|
|
2291
|
+
if (!stateData) {
|
|
2292
|
+
const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
|
|
2293
|
+
throw ctx.redirect(`${errorURL}?error=invalid_state`);
|
|
2294
|
+
}
|
|
2295
|
+
const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
|
|
2296
|
+
if (!code || error) throw ctx.redirect(`${errorURL || callbackURL}?error=${error}&error_description=${error_description}`);
|
|
2297
|
+
let provider = null;
|
|
2298
|
+
if (options?.defaultSSO?.length) {
|
|
2299
|
+
const matchingDefault = options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId);
|
|
2300
|
+
if (matchingDefault) provider = {
|
|
2301
|
+
...matchingDefault,
|
|
2302
|
+
issuer: matchingDefault.oidcConfig?.issuer || "",
|
|
2303
|
+
userId: "default",
|
|
2304
|
+
...options.domainVerification?.enabled ? { domainVerified: true } : {}
|
|
2305
|
+
};
|
|
2306
|
+
}
|
|
2307
|
+
if (!provider) provider = await ctx.context.adapter.findOne({
|
|
2308
|
+
model: "ssoProvider",
|
|
2309
|
+
where: [{
|
|
2310
|
+
field: "providerId",
|
|
2311
|
+
value: providerId
|
|
2312
|
+
}]
|
|
2313
|
+
}).then((res) => {
|
|
2314
|
+
if (!res) return null;
|
|
2315
|
+
return {
|
|
2316
|
+
...res,
|
|
2317
|
+
oidcConfig: safeJsonParse(res.oidcConfig) || void 0
|
|
2318
|
+
};
|
|
2319
|
+
});
|
|
2320
|
+
if (!provider) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
|
|
2321
|
+
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
2322
|
+
let config = provider.oidcConfig;
|
|
2323
|
+
if (!config) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
|
|
2324
|
+
const discovery = await betterFetch(config.discoveryEndpoint);
|
|
2325
|
+
if (discovery.data) config = {
|
|
2326
|
+
tokenEndpoint: discovery.data.token_endpoint,
|
|
2327
|
+
tokenEndpointAuthentication: discovery.data.token_endpoint_auth_method,
|
|
2328
|
+
userInfoEndpoint: discovery.data.userinfo_endpoint,
|
|
2329
|
+
scopes: [
|
|
2330
|
+
"openid",
|
|
2331
|
+
"email",
|
|
2332
|
+
"profile",
|
|
2333
|
+
"offline_access"
|
|
2334
|
+
],
|
|
2335
|
+
...config
|
|
2336
|
+
};
|
|
2337
|
+
if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_endpoint_not_found`);
|
|
2338
|
+
const tokenResponse = await validateAuthorizationCode({
|
|
2339
|
+
code,
|
|
2340
|
+
codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
|
|
2341
|
+
redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
|
|
2342
|
+
options: {
|
|
2343
|
+
clientId: config.clientId,
|
|
2344
|
+
clientSecret: config.clientSecret
|
|
2345
|
+
},
|
|
2346
|
+
tokenEndpoint: config.tokenEndpoint,
|
|
2347
|
+
authentication: config.tokenEndpointAuthentication === "client_secret_post" ? "post" : "basic"
|
|
2348
|
+
}).catch((e) => {
|
|
2349
|
+
if (e instanceof BetterFetchError) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${e.message}`);
|
|
2350
|
+
return null;
|
|
2351
|
+
});
|
|
2352
|
+
if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_response_not_found`);
|
|
2353
|
+
let userInfo = null;
|
|
2354
|
+
if (tokenResponse.idToken) {
|
|
2355
|
+
const idToken = decodeJwt(tokenResponse.idToken);
|
|
2356
|
+
if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=jwks_endpoint_not_found`);
|
|
2357
|
+
const verified = await validateToken(tokenResponse.idToken, config.jwksEndpoint, {
|
|
2358
|
+
audience: config.clientId,
|
|
2359
|
+
issuer: provider.issuer
|
|
2360
|
+
}).catch((e) => {
|
|
2361
|
+
ctx.context.logger.error(e);
|
|
2362
|
+
return null;
|
|
2363
|
+
});
|
|
2364
|
+
if (!verified) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_not_verified`);
|
|
2365
|
+
const mapping = config.mapping || {};
|
|
2366
|
+
userInfo = {
|
|
2367
|
+
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, verified.payload[value]])),
|
|
2368
|
+
id: idToken[mapping.id || "sub"],
|
|
2369
|
+
email: idToken[mapping.email || "email"],
|
|
2370
|
+
emailVerified: options?.trustEmailVerified ? idToken[mapping.emailVerified || "email_verified"] : false,
|
|
2371
|
+
name: idToken[mapping.name || "name"],
|
|
2372
|
+
image: idToken[mapping.image || "picture"]
|
|
2373
|
+
};
|
|
2374
|
+
}
|
|
2375
|
+
if (!userInfo) {
|
|
2376
|
+
if (!config.userInfoEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=user_info_endpoint_not_found`);
|
|
2377
|
+
const userInfoResponse = await betterFetch(config.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } });
|
|
2378
|
+
if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${userInfoResponse.error.message}`);
|
|
2379
|
+
userInfo = userInfoResponse.data;
|
|
2380
|
+
}
|
|
2381
|
+
if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=missing_user_info`);
|
|
2382
|
+
const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfo.email, provider.domain);
|
|
2383
|
+
const linked = await handleOAuthUserInfo(ctx, {
|
|
2384
|
+
userInfo: {
|
|
2385
|
+
email: userInfo.email,
|
|
2386
|
+
name: userInfo.name || "",
|
|
2387
|
+
id: userInfo.id,
|
|
2388
|
+
image: userInfo.image,
|
|
2389
|
+
emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
|
|
2390
|
+
},
|
|
2391
|
+
account: {
|
|
2392
|
+
idToken: tokenResponse.idToken,
|
|
2393
|
+
accessToken: tokenResponse.accessToken,
|
|
2394
|
+
refreshToken: tokenResponse.refreshToken,
|
|
2395
|
+
accountId: userInfo.id,
|
|
2396
|
+
providerId: provider.providerId,
|
|
2397
|
+
accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
|
|
2398
|
+
refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
|
|
2399
|
+
scope: tokenResponse.scopes?.join(",")
|
|
2400
|
+
},
|
|
2401
|
+
callbackURL,
|
|
2402
|
+
disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
|
|
2403
|
+
overrideUserInfo: config.overrideUserInfo,
|
|
2404
|
+
isTrustedProvider
|
|
2405
|
+
});
|
|
2406
|
+
if (linked.error) throw ctx.redirect(`${errorURL || callbackURL}?error=${linked.error}`);
|
|
2407
|
+
const { session, user } = linked.data;
|
|
2408
|
+
if (options?.provisionUser && linked.isRegister) await options.provisionUser({
|
|
2409
|
+
user,
|
|
2410
|
+
userInfo,
|
|
2411
|
+
token: tokenResponse,
|
|
2412
|
+
provider
|
|
2413
|
+
});
|
|
2414
|
+
await assignOrganizationFromProvider(ctx, {
|
|
2415
|
+
user,
|
|
2416
|
+
profile: {
|
|
2417
|
+
providerType: "oidc",
|
|
2418
|
+
providerId: provider.providerId,
|
|
2419
|
+
accountId: userInfo.id,
|
|
2420
|
+
email: userInfo.email,
|
|
2421
|
+
emailVerified: Boolean(userInfo.emailVerified),
|
|
2422
|
+
rawAttributes: userInfo
|
|
2423
|
+
},
|
|
2424
|
+
provider,
|
|
2425
|
+
token: tokenResponse,
|
|
2426
|
+
provisioningOptions: options?.organizationProvisioning
|
|
2427
|
+
});
|
|
2428
|
+
await setSessionCookie(ctx, {
|
|
2429
|
+
session,
|
|
2430
|
+
user
|
|
2431
|
+
});
|
|
2432
|
+
let toRedirectTo;
|
|
2433
|
+
try {
|
|
2434
|
+
toRedirectTo = (linked.isRegister ? newUserURL || callbackURL : callbackURL).toString();
|
|
2435
|
+
} catch {
|
|
2436
|
+
toRedirectTo = linked.isRegister ? newUserURL || callbackURL : callbackURL;
|
|
2437
|
+
}
|
|
2438
|
+
throw ctx.redirect(toRedirectTo);
|
|
2439
|
+
}
|
|
2440
|
+
const callbackSSOEndpointConfig = {
|
|
2441
|
+
method: "GET",
|
|
2442
|
+
query: callbackSSOQuerySchema,
|
|
2443
|
+
allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
|
|
2444
|
+
metadata: {
|
|
2445
|
+
...HIDE_METADATA,
|
|
2446
|
+
openapi: {
|
|
2447
|
+
operationId: "handleSSOCallback",
|
|
2448
|
+
summary: "Callback URL for SSO provider",
|
|
2449
|
+
description: "This endpoint is used as the callback URL for SSO providers. It handles the authorization code and exchanges it for an access token",
|
|
2450
|
+
responses: { "302": { description: "Redirects to the callback URL" } }
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
};
|
|
2144
2454
|
const callbackSSO = (options) => {
|
|
2145
|
-
return createAuthEndpoint("/sso/callback/:providerId", {
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2455
|
+
return createAuthEndpoint("/sso/callback/:providerId", callbackSSOEndpointConfig, async (ctx) => {
|
|
2456
|
+
return handleOIDCCallback(ctx, options, ctx.params.providerId);
|
|
2457
|
+
});
|
|
2458
|
+
};
|
|
2459
|
+
/**
|
|
2460
|
+
* Shared OIDC callback endpoint (no `:providerId` in path).
|
|
2461
|
+
* Used when `options.redirectURI` is set — the `providerId` is read from
|
|
2462
|
+
* the OAuth state instead of the URL path.
|
|
2463
|
+
*/
|
|
2464
|
+
const callbackSSOShared = (options) => {
|
|
2465
|
+
return createAuthEndpoint("/sso/callback", {
|
|
2466
|
+
...callbackSSOEndpointConfig,
|
|
2149
2467
|
metadata: {
|
|
2150
|
-
...
|
|
2468
|
+
...callbackSSOEndpointConfig.metadata,
|
|
2151
2469
|
openapi: {
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2470
|
+
...callbackSSOEndpointConfig.metadata.openapi,
|
|
2471
|
+
operationId: "handleSSOCallbackShared",
|
|
2472
|
+
summary: "Shared callback URL for all SSO providers",
|
|
2473
|
+
description: "This endpoint is used as a shared callback URL for all SSO providers when `redirectURI` is configured. The provider is identified via the OAuth state parameter."
|
|
2156
2474
|
}
|
|
2157
2475
|
}
|
|
2158
2476
|
}, async (ctx) => {
|
|
2159
|
-
const { code, error, error_description } = ctx.query;
|
|
2160
2477
|
const stateData = await parseState(ctx);
|
|
2161
2478
|
if (!stateData) {
|
|
2162
2479
|
const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
|
|
2163
2480
|
throw ctx.redirect(`${errorURL}?error=invalid_state`);
|
|
2164
2481
|
}
|
|
2165
|
-
const
|
|
2166
|
-
if (!
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
const matchingDefault = options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === ctx.params.providerId);
|
|
2170
|
-
if (matchingDefault) provider = {
|
|
2171
|
-
...matchingDefault,
|
|
2172
|
-
issuer: matchingDefault.oidcConfig?.issuer || "",
|
|
2173
|
-
userId: "default",
|
|
2174
|
-
...options.domainVerification?.enabled ? { domainVerified: true } : {}
|
|
2175
|
-
};
|
|
2176
|
-
}
|
|
2177
|
-
if (!provider) provider = await ctx.context.adapter.findOne({
|
|
2178
|
-
model: "ssoProvider",
|
|
2179
|
-
where: [{
|
|
2180
|
-
field: "providerId",
|
|
2181
|
-
value: ctx.params.providerId
|
|
2182
|
-
}]
|
|
2183
|
-
}).then((res) => {
|
|
2184
|
-
if (!res) return null;
|
|
2185
|
-
return {
|
|
2186
|
-
...res,
|
|
2187
|
-
oidcConfig: safeJsonParse(res.oidcConfig) || void 0
|
|
2188
|
-
};
|
|
2189
|
-
});
|
|
2190
|
-
if (!provider) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
|
|
2191
|
-
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
2192
|
-
let config = provider.oidcConfig;
|
|
2193
|
-
if (!config) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
|
|
2194
|
-
const discovery = await betterFetch(config.discoveryEndpoint);
|
|
2195
|
-
if (discovery.data) config = {
|
|
2196
|
-
tokenEndpoint: discovery.data.token_endpoint,
|
|
2197
|
-
tokenEndpointAuthentication: discovery.data.token_endpoint_auth_method,
|
|
2198
|
-
userInfoEndpoint: discovery.data.userinfo_endpoint,
|
|
2199
|
-
scopes: [
|
|
2200
|
-
"openid",
|
|
2201
|
-
"email",
|
|
2202
|
-
"profile",
|
|
2203
|
-
"offline_access"
|
|
2204
|
-
],
|
|
2205
|
-
...config
|
|
2206
|
-
};
|
|
2207
|
-
if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_endpoint_not_found`);
|
|
2208
|
-
const tokenResponse = await validateAuthorizationCode({
|
|
2209
|
-
code,
|
|
2210
|
-
codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
|
|
2211
|
-
redirectURI: `${ctx.context.baseURL}/sso/callback/${provider.providerId}`,
|
|
2212
|
-
options: {
|
|
2213
|
-
clientId: config.clientId,
|
|
2214
|
-
clientSecret: config.clientSecret
|
|
2215
|
-
},
|
|
2216
|
-
tokenEndpoint: config.tokenEndpoint,
|
|
2217
|
-
authentication: config.tokenEndpointAuthentication === "client_secret_post" ? "post" : "basic"
|
|
2218
|
-
}).catch((e) => {
|
|
2219
|
-
if (e instanceof BetterFetchError) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${e.message}`);
|
|
2220
|
-
return null;
|
|
2221
|
-
});
|
|
2222
|
-
if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_response_not_found`);
|
|
2223
|
-
let userInfo = null;
|
|
2224
|
-
if (tokenResponse.idToken) {
|
|
2225
|
-
const idToken = decodeJwt(tokenResponse.idToken);
|
|
2226
|
-
if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=jwks_endpoint_not_found`);
|
|
2227
|
-
const verified = await validateToken(tokenResponse.idToken, config.jwksEndpoint, {
|
|
2228
|
-
audience: config.clientId,
|
|
2229
|
-
issuer: provider.issuer
|
|
2230
|
-
}).catch((e) => {
|
|
2231
|
-
ctx.context.logger.error(e);
|
|
2232
|
-
return null;
|
|
2233
|
-
});
|
|
2234
|
-
if (!verified) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_not_verified`);
|
|
2235
|
-
const mapping = config.mapping || {};
|
|
2236
|
-
userInfo = {
|
|
2237
|
-
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, verified.payload[value]])),
|
|
2238
|
-
id: idToken[mapping.id || "sub"],
|
|
2239
|
-
email: idToken[mapping.email || "email"],
|
|
2240
|
-
emailVerified: options?.trustEmailVerified ? idToken[mapping.emailVerified || "email_verified"] : false,
|
|
2241
|
-
name: idToken[mapping.name || "name"],
|
|
2242
|
-
image: idToken[mapping.image || "picture"]
|
|
2243
|
-
};
|
|
2244
|
-
}
|
|
2245
|
-
if (!userInfo) {
|
|
2246
|
-
if (!config.userInfoEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=user_info_endpoint_not_found`);
|
|
2247
|
-
const userInfoResponse = await betterFetch(config.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } });
|
|
2248
|
-
if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${userInfoResponse.error.message}`);
|
|
2249
|
-
userInfo = userInfoResponse.data;
|
|
2482
|
+
const providerId = stateData.ssoProviderId;
|
|
2483
|
+
if (!providerId) {
|
|
2484
|
+
const errorURL = stateData.errorURL || stateData.callbackURL;
|
|
2485
|
+
throw ctx.redirect(`${errorURL}?error=invalid_state&error_description=missing_provider_id`);
|
|
2250
2486
|
}
|
|
2251
|
-
|
|
2252
|
-
const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfo.email, provider.domain);
|
|
2253
|
-
const linked = await handleOAuthUserInfo(ctx, {
|
|
2254
|
-
userInfo: {
|
|
2255
|
-
email: userInfo.email,
|
|
2256
|
-
name: userInfo.name || userInfo.email,
|
|
2257
|
-
id: userInfo.id,
|
|
2258
|
-
image: userInfo.image,
|
|
2259
|
-
emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
|
|
2260
|
-
},
|
|
2261
|
-
account: {
|
|
2262
|
-
idToken: tokenResponse.idToken,
|
|
2263
|
-
accessToken: tokenResponse.accessToken,
|
|
2264
|
-
refreshToken: tokenResponse.refreshToken,
|
|
2265
|
-
accountId: userInfo.id,
|
|
2266
|
-
providerId: provider.providerId,
|
|
2267
|
-
accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
|
|
2268
|
-
refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
|
|
2269
|
-
scope: tokenResponse.scopes?.join(",")
|
|
2270
|
-
},
|
|
2271
|
-
callbackURL,
|
|
2272
|
-
disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
|
|
2273
|
-
overrideUserInfo: config.overrideUserInfo,
|
|
2274
|
-
isTrustedProvider
|
|
2275
|
-
});
|
|
2276
|
-
if (linked.error) throw ctx.redirect(`${errorURL || callbackURL}?error=${linked.error}`);
|
|
2277
|
-
const { session, user } = linked.data;
|
|
2278
|
-
if (options?.provisionUser) await options.provisionUser({
|
|
2279
|
-
user,
|
|
2280
|
-
userInfo,
|
|
2281
|
-
token: tokenResponse,
|
|
2282
|
-
provider
|
|
2283
|
-
});
|
|
2284
|
-
await assignOrganizationFromProvider(ctx, {
|
|
2285
|
-
user,
|
|
2286
|
-
profile: {
|
|
2287
|
-
providerType: "oidc",
|
|
2288
|
-
providerId: provider.providerId,
|
|
2289
|
-
accountId: userInfo.id,
|
|
2290
|
-
email: userInfo.email,
|
|
2291
|
-
emailVerified: Boolean(userInfo.emailVerified),
|
|
2292
|
-
rawAttributes: userInfo
|
|
2293
|
-
},
|
|
2294
|
-
provider,
|
|
2295
|
-
token: tokenResponse,
|
|
2296
|
-
provisioningOptions: options?.organizationProvisioning
|
|
2297
|
-
});
|
|
2298
|
-
await setSessionCookie(ctx, {
|
|
2299
|
-
session,
|
|
2300
|
-
user
|
|
2301
|
-
});
|
|
2302
|
-
let toRedirectTo;
|
|
2303
|
-
try {
|
|
2304
|
-
toRedirectTo = (linked.isRegister ? newUserURL || callbackURL : callbackURL).toString();
|
|
2305
|
-
} catch {
|
|
2306
|
-
toRedirectTo = linked.isRegister ? newUserURL || callbackURL : callbackURL;
|
|
2307
|
-
}
|
|
2308
|
-
throw ctx.redirect(toRedirectTo);
|
|
2487
|
+
return handleOIDCCallback(ctx, options, providerId, stateData);
|
|
2309
2488
|
});
|
|
2310
2489
|
};
|
|
2311
2490
|
const callbackSSOSAMLBodySchema = z.object({
|
|
@@ -2563,7 +2742,7 @@ const callbackSSOSAML = (options) => {
|
|
|
2563
2742
|
});
|
|
2564
2743
|
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
2565
2744
|
}
|
|
2566
|
-
const isTrustedProvider =
|
|
2745
|
+
const isTrustedProvider = ctx.context.trustedProviders.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
2567
2746
|
const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2568
2747
|
const result = await handleOAuthUserInfo(ctx, {
|
|
2569
2748
|
userInfo: {
|
|
@@ -2606,6 +2785,25 @@ const callbackSSOSAML = (options) => {
|
|
|
2606
2785
|
session,
|
|
2607
2786
|
user
|
|
2608
2787
|
});
|
|
2788
|
+
if (options?.saml?.enableSingleLogout && extract.nameID) {
|
|
2789
|
+
const samlSessionKey = `${SAML_SESSION_KEY_PREFIX}${provider.providerId}:${extract.nameID}`;
|
|
2790
|
+
const samlSessionData = {
|
|
2791
|
+
sessionId: session.id,
|
|
2792
|
+
providerId: provider.providerId,
|
|
2793
|
+
nameID: extract.nameID,
|
|
2794
|
+
sessionIndex: extract.sessionIndex
|
|
2795
|
+
};
|
|
2796
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
2797
|
+
identifier: samlSessionKey,
|
|
2798
|
+
value: JSON.stringify(samlSessionData),
|
|
2799
|
+
expiresAt: session.expiresAt
|
|
2800
|
+
}).catch((e) => ctx.context.logger.warn("Failed to create SAML session record", { error: e }));
|
|
2801
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
2802
|
+
identifier: `${SAML_SESSION_BY_ID_PREFIX}${session.id}`,
|
|
2803
|
+
value: samlSessionKey,
|
|
2804
|
+
expiresAt: session.expiresAt
|
|
2805
|
+
}).catch((e) => ctx.context.logger.warn("Failed to create SAML session lookup record", e));
|
|
2806
|
+
}
|
|
2609
2807
|
const safeRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
2610
2808
|
throw ctx.redirect(safeRedirectUrl);
|
|
2611
2809
|
});
|
|
@@ -2815,7 +3013,7 @@ const acsEndpoint = (options) => {
|
|
|
2815
3013
|
});
|
|
2816
3014
|
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
2817
3015
|
}
|
|
2818
|
-
const isTrustedProvider =
|
|
3016
|
+
const isTrustedProvider = ctx.context.trustedProviders.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
2819
3017
|
const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2820
3018
|
const result = await handleOAuthUserInfo(ctx, {
|
|
2821
3019
|
userInfo: {
|
|
@@ -2858,10 +3056,186 @@ const acsEndpoint = (options) => {
|
|
|
2858
3056
|
session,
|
|
2859
3057
|
user
|
|
2860
3058
|
});
|
|
3059
|
+
if (options?.saml?.enableSingleLogout && extract.nameID) {
|
|
3060
|
+
const samlSessionKey = `${SAML_SESSION_KEY_PREFIX}${providerId}:${extract.nameID}`;
|
|
3061
|
+
const samlSessionData = {
|
|
3062
|
+
sessionId: session.id,
|
|
3063
|
+
providerId,
|
|
3064
|
+
nameID: extract.nameID,
|
|
3065
|
+
sessionIndex: extract.sessionIndex
|
|
3066
|
+
};
|
|
3067
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
3068
|
+
identifier: samlSessionKey,
|
|
3069
|
+
value: JSON.stringify(samlSessionData),
|
|
3070
|
+
expiresAt: session.expiresAt
|
|
3071
|
+
}).catch((e) => ctx.context.logger.warn("Failed to create SAML session record", { error: e }));
|
|
3072
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
3073
|
+
identifier: `${SAML_SESSION_BY_ID_PREFIX}${session.id}`,
|
|
3074
|
+
value: samlSessionKey,
|
|
3075
|
+
expiresAt: session.expiresAt
|
|
3076
|
+
}).catch((e) => ctx.context.logger.warn("Failed to create SAML session lookup record", e));
|
|
3077
|
+
}
|
|
2861
3078
|
const safeRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
2862
3079
|
throw ctx.redirect(safeRedirectUrl);
|
|
2863
3080
|
});
|
|
2864
3081
|
};
|
|
3082
|
+
const sloSchema = z.object({
|
|
3083
|
+
SAMLRequest: z.string().optional(),
|
|
3084
|
+
SAMLResponse: z.string().optional(),
|
|
3085
|
+
RelayState: z.string().optional(),
|
|
3086
|
+
SigAlg: z.string().optional(),
|
|
3087
|
+
Signature: z.string().optional()
|
|
3088
|
+
});
|
|
3089
|
+
const sloEndpoint = (options) => {
|
|
3090
|
+
return createAuthEndpoint("/sso/saml2/sp/slo/:providerId", {
|
|
3091
|
+
method: ["GET", "POST"],
|
|
3092
|
+
body: sloSchema.optional(),
|
|
3093
|
+
query: sloSchema.optional(),
|
|
3094
|
+
metadata: {
|
|
3095
|
+
...HIDE_METADATA,
|
|
3096
|
+
allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"]
|
|
3097
|
+
}
|
|
3098
|
+
}, async (ctx) => {
|
|
3099
|
+
if (!options?.saml?.enableSingleLogout) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.SINGLE_LOGOUT_NOT_ENABLED);
|
|
3100
|
+
const { providerId } = ctx.params;
|
|
3101
|
+
const samlRequest = ctx.body?.SAMLRequest || ctx.query?.SAMLRequest;
|
|
3102
|
+
const samlResponse = ctx.body?.SAMLResponse || ctx.query?.SAMLResponse;
|
|
3103
|
+
const relayState = ctx.body?.RelayState || ctx.query?.RelayState;
|
|
3104
|
+
const appOrigin = new URL(ctx.context.baseURL).origin;
|
|
3105
|
+
const safeErrorURL = getSafeRedirectUrl(relayState, `${appOrigin}/sso/saml2/sp/slo/${providerId}`, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
3106
|
+
if (!samlRequest && !samlResponse) throw ctx.redirect(`${safeErrorURL}?error=invalid_request&error_description=missing_logout_data`);
|
|
3107
|
+
const provider = await findSAMLProvider(providerId, options, ctx.context.adapter);
|
|
3108
|
+
if (!provider?.samlConfig) throw APIError.from("NOT_FOUND", SAML_ERROR_CODES.SAML_PROVIDER_NOT_FOUND);
|
|
3109
|
+
const config = provider.samlConfig;
|
|
3110
|
+
const sp = createSP(config, ctx.context.baseURL, providerId, {
|
|
3111
|
+
wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
|
|
3112
|
+
wantLogoutResponseSigned: options?.saml?.wantLogoutResponseSigned
|
|
3113
|
+
});
|
|
3114
|
+
const idp = createIdP(config);
|
|
3115
|
+
if (samlResponse) return handleLogoutResponse(ctx, sp, idp, relayState, providerId);
|
|
3116
|
+
return handleLogoutRequest(ctx, sp, idp, relayState, providerId);
|
|
3117
|
+
});
|
|
3118
|
+
};
|
|
3119
|
+
async function handleLogoutResponse(ctx, sp, idp, relayState, providerId) {
|
|
3120
|
+
const binding = ctx.method === "POST" && ctx.body?.SAMLResponse ? "post" : "redirect";
|
|
3121
|
+
let parsed;
|
|
3122
|
+
try {
|
|
3123
|
+
parsed = await sp.parseLogoutResponse(idp, binding, {
|
|
3124
|
+
body: ctx.body,
|
|
3125
|
+
query: ctx.query
|
|
3126
|
+
});
|
|
3127
|
+
} catch (error) {
|
|
3128
|
+
ctx.context.logger.error("LogoutResponse validation failed", { error });
|
|
3129
|
+
throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.INVALID_LOGOUT_RESPONSE);
|
|
3130
|
+
}
|
|
3131
|
+
const extract = parsed?.extract;
|
|
3132
|
+
const statusCode = extract?.statusCode || extract?.status || parsed?.samlContent?.status?.statusCode;
|
|
3133
|
+
if (statusCode && statusCode !== SAML_STATUS_SUCCESS) {
|
|
3134
|
+
ctx.context.logger.warn("LogoutResponse indicates failure", { statusCode });
|
|
3135
|
+
throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.LOGOUT_FAILED_AT_IDP);
|
|
3136
|
+
}
|
|
3137
|
+
const inResponseTo = extract?.response?.inResponseTo;
|
|
3138
|
+
if (inResponseTo) {
|
|
3139
|
+
const key = `${LOGOUT_REQUEST_KEY_PREFIX}${inResponseTo}`;
|
|
3140
|
+
if (!await ctx.context.internalAdapter.findVerificationValue(key)) ctx.context.logger.warn("LogoutResponse references unknown or expired request", { inResponseTo });
|
|
3141
|
+
await ctx.context.internalAdapter.deleteVerificationValue(key).catch((e) => ctx.context.logger.warn("Failed to delete logout request verification value", e));
|
|
3142
|
+
}
|
|
3143
|
+
deleteSessionCookie(ctx);
|
|
3144
|
+
const appOrigin = new URL(ctx.context.baseURL).origin;
|
|
3145
|
+
const safeRedirectUrl = getSafeRedirectUrl(relayState, `${appOrigin}/sso/saml2/sp/slo/${providerId}`, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
3146
|
+
throw ctx.redirect(safeRedirectUrl);
|
|
3147
|
+
}
|
|
3148
|
+
async function handleLogoutRequest(ctx, sp, idp, relayState, providerId) {
|
|
3149
|
+
const binding = ctx.method === "POST" && ctx.body?.SAMLRequest ? "post" : "redirect";
|
|
3150
|
+
let parsed;
|
|
3151
|
+
try {
|
|
3152
|
+
parsed = await sp.parseLogoutRequest(idp, binding, {
|
|
3153
|
+
body: ctx.body,
|
|
3154
|
+
query: ctx.query
|
|
3155
|
+
});
|
|
3156
|
+
} catch (error) {
|
|
3157
|
+
ctx.context.logger.error("LogoutRequest validation failed", { error });
|
|
3158
|
+
throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.INVALID_LOGOUT_REQUEST);
|
|
3159
|
+
}
|
|
3160
|
+
if (!parsed?.extract) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.INVALID_LOGOUT_REQUEST);
|
|
3161
|
+
const { nameID } = parsed.extract;
|
|
3162
|
+
const sessionIndex = parsed.extract.sessionIndex;
|
|
3163
|
+
const key = `${SAML_SESSION_KEY_PREFIX}${providerId}:${nameID}`;
|
|
3164
|
+
const stored = await ctx.context.internalAdapter.findVerificationValue(key);
|
|
3165
|
+
if (stored) {
|
|
3166
|
+
const data = safeJsonParse(stored.value);
|
|
3167
|
+
if (data) if (!sessionIndex || !data.sessionIndex || sessionIndex === data.sessionIndex) {
|
|
3168
|
+
await ctx.context.internalAdapter.deleteSession(data.sessionId).catch((e) => ctx.context.logger.warn("Failed to delete session during SLO", { error: e }));
|
|
3169
|
+
await ctx.context.internalAdapter.deleteVerificationValue(`${SAML_SESSION_BY_ID_PREFIX}${data.sessionId}`).catch((e) => ctx.context.logger.warn("Failed to delete SAML session lookup during SLO", e));
|
|
3170
|
+
} else ctx.context.logger.warn("SessionIndex mismatch in LogoutRequest - skipping session deletion", {
|
|
3171
|
+
providerId,
|
|
3172
|
+
requestedSessionIndex: sessionIndex,
|
|
3173
|
+
storedSessionIndex: data.sessionIndex
|
|
3174
|
+
});
|
|
3175
|
+
await ctx.context.internalAdapter.deleteVerificationValue(key).catch((e) => ctx.context.logger.warn("Failed to delete SAML session key during SLO", e));
|
|
3176
|
+
}
|
|
3177
|
+
const currentSession = await getSessionFromCtx(ctx);
|
|
3178
|
+
if (currentSession?.session) await ctx.context.internalAdapter.deleteSession(currentSession.session.id);
|
|
3179
|
+
deleteSessionCookie(ctx);
|
|
3180
|
+
const requestId = parsed.extract.request?.id || "";
|
|
3181
|
+
const res = sp.createLogoutResponse(idp, null, binding, relayState || "", (template) => template.replace("{InResponseTo}", requestId).replace("{StatusCode}", SAML_STATUS_SUCCESS));
|
|
3182
|
+
if (binding === "post" && res.entityEndpoint) return createSAMLPostForm(res.entityEndpoint, "SAMLResponse", res.context, relayState);
|
|
3183
|
+
throw ctx.redirect(res.context);
|
|
3184
|
+
}
|
|
3185
|
+
const initiateSLO = (options) => {
|
|
3186
|
+
return createAuthEndpoint("/sso/saml2/logout/:providerId", {
|
|
3187
|
+
method: "POST",
|
|
3188
|
+
body: z.object({ callbackURL: z.string().optional() }),
|
|
3189
|
+
use: [sessionMiddleware],
|
|
3190
|
+
metadata: HIDE_METADATA
|
|
3191
|
+
}, async (ctx) => {
|
|
3192
|
+
if (!options?.saml?.enableSingleLogout) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.SINGLE_LOGOUT_NOT_ENABLED);
|
|
3193
|
+
const { providerId } = ctx.params;
|
|
3194
|
+
const callbackURL = ctx.body.callbackURL || ctx.context.baseURL;
|
|
3195
|
+
const provider = await findSAMLProvider(providerId, options, ctx.context.adapter);
|
|
3196
|
+
if (!provider?.samlConfig) throw APIError.from("NOT_FOUND", SAML_ERROR_CODES.SAML_PROVIDER_NOT_FOUND);
|
|
3197
|
+
const config = provider.samlConfig;
|
|
3198
|
+
if (!(config.idpMetadata?.singleLogoutService?.length || config.idpMetadata?.metadata && config.idpMetadata.metadata.includes("SingleLogoutService"))) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.IDP_SLO_NOT_SUPPORTED);
|
|
3199
|
+
const sp = createSP(config, ctx.context.baseURL, providerId, {
|
|
3200
|
+
wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
|
|
3201
|
+
wantLogoutResponseSigned: options?.saml?.wantLogoutResponseSigned
|
|
3202
|
+
});
|
|
3203
|
+
const idp = createIdP(config);
|
|
3204
|
+
const session = ctx.context.session;
|
|
3205
|
+
const sessionLookupKey = `${SAML_SESSION_BY_ID_PREFIX}${session.session.id}`;
|
|
3206
|
+
const sessionLookup = await ctx.context.internalAdapter.findVerificationValue(sessionLookupKey);
|
|
3207
|
+
let nameID = session.user.email;
|
|
3208
|
+
let sessionIndex;
|
|
3209
|
+
let samlSessionKey;
|
|
3210
|
+
if (sessionLookup) {
|
|
3211
|
+
samlSessionKey = sessionLookup.value;
|
|
3212
|
+
const stored = await ctx.context.internalAdapter.findVerificationValue(samlSessionKey);
|
|
3213
|
+
if (stored) {
|
|
3214
|
+
const data = safeJsonParse(stored.value);
|
|
3215
|
+
if (data) {
|
|
3216
|
+
nameID = data.nameID || nameID;
|
|
3217
|
+
sessionIndex = data.sessionIndex;
|
|
3218
|
+
}
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
3221
|
+
const logoutRequest = sp.createLogoutRequest(idp, "redirect", {
|
|
3222
|
+
logoutNameID: nameID,
|
|
3223
|
+
sessionIndex,
|
|
3224
|
+
relayState: callbackURL
|
|
3225
|
+
});
|
|
3226
|
+
const ttl = options?.saml?.logoutRequestTTL ?? DEFAULT_LOGOUT_REQUEST_TTL_MS;
|
|
3227
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
3228
|
+
identifier: `${LOGOUT_REQUEST_KEY_PREFIX}${logoutRequest.id}`,
|
|
3229
|
+
value: providerId,
|
|
3230
|
+
expiresAt: new Date(Date.now() + ttl)
|
|
3231
|
+
});
|
|
3232
|
+
if (samlSessionKey) await ctx.context.internalAdapter.deleteVerificationValue(samlSessionKey).catch((e) => ctx.context.logger.warn("Failed to delete SAML session key during logout", e));
|
|
3233
|
+
await ctx.context.internalAdapter.deleteVerificationValue(sessionLookupKey).catch((e) => ctx.context.logger.warn("Failed to delete session lookup key during logout", e));
|
|
3234
|
+
await ctx.context.internalAdapter.deleteSession(session.session.id);
|
|
3235
|
+
deleteSessionCookie(ctx);
|
|
3236
|
+
throw ctx.redirect(logoutRequest.context);
|
|
3237
|
+
});
|
|
3238
|
+
};
|
|
2865
3239
|
|
|
2866
3240
|
//#endregion
|
|
2867
3241
|
//#region src/index.ts
|
|
@@ -2874,16 +3248,23 @@ saml.setSchemaValidator({ async validate(xml) {
|
|
|
2874
3248
|
* These endpoints receive POST requests from external Identity Providers,
|
|
2875
3249
|
* which won't have a matching Origin header.
|
|
2876
3250
|
*/
|
|
2877
|
-
const SAML_SKIP_ORIGIN_CHECK_PATHS = [
|
|
3251
|
+
const SAML_SKIP_ORIGIN_CHECK_PATHS = [
|
|
3252
|
+
"/sso/saml2/callback",
|
|
3253
|
+
"/sso/saml2/sp/acs",
|
|
3254
|
+
"/sso/saml2/sp/slo"
|
|
3255
|
+
];
|
|
2878
3256
|
function sso(options) {
|
|
2879
3257
|
const optionsWithStore = options;
|
|
2880
3258
|
let endpoints = {
|
|
2881
|
-
spMetadata: spMetadata(),
|
|
3259
|
+
spMetadata: spMetadata(optionsWithStore),
|
|
2882
3260
|
registerSSOProvider: registerSSOProvider(optionsWithStore),
|
|
2883
3261
|
signInSSO: signInSSO(optionsWithStore),
|
|
2884
3262
|
callbackSSO: callbackSSO(optionsWithStore),
|
|
3263
|
+
callbackSSOShared: callbackSSOShared(optionsWithStore),
|
|
2885
3264
|
callbackSSOSAML: callbackSSOSAML(optionsWithStore),
|
|
2886
3265
|
acsEndpoint: acsEndpoint(optionsWithStore),
|
|
3266
|
+
sloEndpoint: sloEndpoint(optionsWithStore),
|
|
3267
|
+
initiateSLO: initiateSLO(optionsWithStore),
|
|
2887
3268
|
listSSOProviders: listSSOProviders(),
|
|
2888
3269
|
getSSOProvider: getSSOProvider(),
|
|
2889
3270
|
updateSSOProvider: updateSSOProvider(optionsWithStore),
|
|
@@ -2907,21 +3288,39 @@ function sso(options) {
|
|
|
2907
3288
|
return { context: { skipOriginCheck: [...Array.isArray(existing) ? existing : [], ...SAML_SKIP_ORIGIN_CHECK_PATHS] } };
|
|
2908
3289
|
},
|
|
2909
3290
|
endpoints,
|
|
2910
|
-
hooks: {
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
3291
|
+
hooks: {
|
|
3292
|
+
before: [{
|
|
3293
|
+
matcher(context) {
|
|
3294
|
+
return context.path === "/sign-out";
|
|
3295
|
+
},
|
|
3296
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
3297
|
+
if (!options?.saml?.enableSingleLogout) return;
|
|
3298
|
+
const session = await getSessionFromCtx(ctx);
|
|
3299
|
+
if (!session?.session?.id) return;
|
|
3300
|
+
const sessionLookupKey = `${SAML_SESSION_BY_ID_PREFIX}${session.session.id}`;
|
|
3301
|
+
const sessionLookup = await ctx.context.internalAdapter.findVerificationValue(sessionLookupKey);
|
|
3302
|
+
if (sessionLookup?.value) {
|
|
3303
|
+
await ctx.context.internalAdapter.deleteVerificationValue(sessionLookup.value).catch(() => {});
|
|
3304
|
+
await ctx.context.internalAdapter.deleteVerificationValue(sessionLookupKey).catch(() => {});
|
|
3305
|
+
}
|
|
3306
|
+
})
|
|
3307
|
+
}],
|
|
3308
|
+
after: [{
|
|
3309
|
+
matcher(context) {
|
|
3310
|
+
return context.path?.startsWith("/callback/") ?? false;
|
|
3311
|
+
},
|
|
3312
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
3313
|
+
const newSession = ctx.context.newSession;
|
|
3314
|
+
if (!newSession?.user) return;
|
|
3315
|
+
if (!ctx.context.hasPlugin("organization")) return;
|
|
3316
|
+
await assignOrganizationByDomain(ctx, {
|
|
3317
|
+
user: newSession.user,
|
|
3318
|
+
provisioningOptions: options?.organizationProvisioning,
|
|
3319
|
+
domainVerification: options?.domainVerification
|
|
3320
|
+
});
|
|
3321
|
+
})
|
|
3322
|
+
}]
|
|
3323
|
+
},
|
|
2925
3324
|
schema: { ssoProvider: {
|
|
2926
3325
|
modelName: options?.modelName ?? "ssoProvider",
|
|
2927
3326
|
fields: {
|