@better-auth/sso 1.5.0-beta.13 → 1.5.0-beta.15
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 +635 -236
- 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 +930 -359
- 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/src/routes/sso.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
getSessionFromCtx,
|
|
15
15
|
sessionMiddleware,
|
|
16
16
|
} from "better-auth/api";
|
|
17
|
-
import { setSessionCookie } from "better-auth/cookies";
|
|
17
|
+
import { deleteSessionCookie, setSessionCookie } from "better-auth/cookies";
|
|
18
18
|
import { generateRandomString } from "better-auth/crypto";
|
|
19
19
|
import { handleOAuthUserInfo } from "better-auth/oauth2";
|
|
20
20
|
import { XMLParser } from "fast-xml-parser";
|
|
@@ -24,6 +24,7 @@ import type { BindingContext } from "samlify/types/src/entity";
|
|
|
24
24
|
import type { IdentityProvider } from "samlify/types/src/entity-idp";
|
|
25
25
|
import type { FlowResult } from "samlify/types/src/flow";
|
|
26
26
|
import z from "zod/v4";
|
|
27
|
+
import { getVerificationIdentifier } from "./domain-verification";
|
|
27
28
|
|
|
28
29
|
interface AuthnRequestRecord {
|
|
29
30
|
id: string;
|
|
@@ -32,15 +33,7 @@ interface AuthnRequestRecord {
|
|
|
32
33
|
expiresAt: number;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
import
|
|
36
|
-
AUTHN_REQUEST_KEY_PREFIX,
|
|
37
|
-
DEFAULT_ASSERTION_TTL_MS,
|
|
38
|
-
DEFAULT_AUTHN_REQUEST_TTL_MS,
|
|
39
|
-
DEFAULT_CLOCK_SKEW_MS,
|
|
40
|
-
DEFAULT_MAX_SAML_METADATA_SIZE,
|
|
41
|
-
DEFAULT_MAX_SAML_RESPONSE_SIZE,
|
|
42
|
-
USED_ASSERTION_KEY_PREFIX,
|
|
43
|
-
} from "../constants";
|
|
36
|
+
import * as constants from "../constants";
|
|
44
37
|
import { assignOrganizationFromProvider } from "../linking";
|
|
45
38
|
import type { HydratedOIDCConfig } from "../oidc";
|
|
46
39
|
import {
|
|
@@ -53,9 +46,48 @@ import {
|
|
|
53
46
|
validateSAMLAlgorithms,
|
|
54
47
|
validateSingleAssertion,
|
|
55
48
|
} from "../saml";
|
|
49
|
+
import { SAML_ERROR_CODES } from "../saml/error-codes";
|
|
56
50
|
import { generateRelayState, parseRelayState } from "../saml-state";
|
|
57
|
-
import type {
|
|
51
|
+
import type {
|
|
52
|
+
OIDCConfig,
|
|
53
|
+
SAMLAssertionExtract,
|
|
54
|
+
SAMLConfig,
|
|
55
|
+
SAMLSessionRecord,
|
|
56
|
+
SSOOptions,
|
|
57
|
+
SSOProvider,
|
|
58
|
+
} from "../types";
|
|
58
59
|
import { domainMatches, safeJsonParse, validateEmailDomain } from "../utils";
|
|
60
|
+
import {
|
|
61
|
+
createIdP,
|
|
62
|
+
createSAMLPostForm,
|
|
63
|
+
createSP,
|
|
64
|
+
findSAMLProvider,
|
|
65
|
+
} from "./helpers";
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Builds the OIDC redirect URI. Uses the shared `redirectURI` option
|
|
69
|
+
* when set, otherwise falls back to `/sso/callback/:providerId`.
|
|
70
|
+
*/
|
|
71
|
+
function getOIDCRedirectURI(
|
|
72
|
+
baseURL: string,
|
|
73
|
+
providerId: string,
|
|
74
|
+
options?: SSOOptions,
|
|
75
|
+
): string {
|
|
76
|
+
if (options?.redirectURI?.trim()) {
|
|
77
|
+
try {
|
|
78
|
+
// Full URL — use as-is
|
|
79
|
+
new URL(options.redirectURI);
|
|
80
|
+
return options.redirectURI;
|
|
81
|
+
} catch {
|
|
82
|
+
// Relative path — append to baseURL
|
|
83
|
+
const path = options.redirectURI.startsWith("/")
|
|
84
|
+
? options.redirectURI
|
|
85
|
+
: `/${options.redirectURI}`;
|
|
86
|
+
return `${baseURL}${path}`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return `${baseURL}/sso/callback/${providerId}`;
|
|
90
|
+
}
|
|
59
91
|
|
|
60
92
|
export interface TimestampValidationOptions {
|
|
61
93
|
clockSkew?: number;
|
|
@@ -80,7 +112,7 @@ export function validateSAMLTimestamp(
|
|
|
80
112
|
conditions: SAMLConditions | undefined,
|
|
81
113
|
options: TimestampValidationOptions = {},
|
|
82
114
|
): void {
|
|
83
|
-
const clockSkew = options.clockSkew ?? DEFAULT_CLOCK_SKEW_MS;
|
|
115
|
+
const clockSkew = options.clockSkew ?? constants.DEFAULT_CLOCK_SKEW_MS;
|
|
84
116
|
const hasTimestamps = conditions?.notBefore || conditions?.notOnOrAfter;
|
|
85
117
|
|
|
86
118
|
if (!hasTimestamps) {
|
|
@@ -169,7 +201,7 @@ const spMetadataQuerySchema = z.object({
|
|
|
169
201
|
|
|
170
202
|
type RelayState = Awaited<ReturnType<typeof parseRelayState>>;
|
|
171
203
|
|
|
172
|
-
export const spMetadata = () => {
|
|
204
|
+
export const spMetadata = (options?: SSOOptions) => {
|
|
173
205
|
return createAuthEndpoint(
|
|
174
206
|
"/sso/saml2/sp/metadata",
|
|
175
207
|
{
|
|
@@ -213,6 +245,21 @@ export const spMetadata = () => {
|
|
|
213
245
|
message: "Invalid SAML configuration",
|
|
214
246
|
});
|
|
215
247
|
}
|
|
248
|
+
|
|
249
|
+
const sloLocation = `${ctx.context.baseURL}/sso/saml2/sp/slo/${ctx.query.providerId}`;
|
|
250
|
+
const singleLogoutService = options?.saml?.enableSingleLogout
|
|
251
|
+
? [
|
|
252
|
+
{
|
|
253
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
254
|
+
Location: sloLocation,
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
258
|
+
Location: sloLocation,
|
|
259
|
+
},
|
|
260
|
+
]
|
|
261
|
+
: undefined;
|
|
262
|
+
|
|
216
263
|
const sp = parsedSamlConfig.spMetadata.metadata
|
|
217
264
|
? saml.ServiceProvider({
|
|
218
265
|
metadata: parsedSamlConfig.spMetadata.metadata,
|
|
@@ -228,6 +275,7 @@ export const spMetadata = () => {
|
|
|
228
275
|
`${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`,
|
|
229
276
|
},
|
|
230
277
|
],
|
|
278
|
+
singleLogoutService,
|
|
231
279
|
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
232
280
|
authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
|
|
233
281
|
nameIDFormat: parsedSamlConfig.identifierFormat
|
|
@@ -681,7 +729,8 @@ export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
|
|
|
681
729
|
|
|
682
730
|
if (body.samlConfig?.idpMetadata?.metadata) {
|
|
683
731
|
const maxMetadataSize =
|
|
684
|
-
options?.saml?.maxMetadataSize ??
|
|
732
|
+
options?.saml?.maxMetadataSize ??
|
|
733
|
+
constants.DEFAULT_MAX_SAML_METADATA_SIZE;
|
|
685
734
|
if (
|
|
686
735
|
new TextEncoder().encode(body.samlConfig.idpMetadata.metadata)
|
|
687
736
|
.length > maxMetadataSize
|
|
@@ -863,9 +912,7 @@ export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
|
|
|
863
912
|
await ctx.context.adapter.create<Verification>({
|
|
864
913
|
model: "verification",
|
|
865
914
|
data: {
|
|
866
|
-
identifier: options.
|
|
867
|
-
? `${options.domainVerification?.tokenPrefix}-${provider.providerId}`
|
|
868
|
-
: `better-auth-token-${provider.providerId}`,
|
|
915
|
+
identifier: getVerificationIdentifier(options, provider.providerId),
|
|
869
916
|
createdAt: new Date(),
|
|
870
917
|
updatedAt: new Date(),
|
|
871
918
|
value: domainVerificationToken as string,
|
|
@@ -895,7 +942,11 @@ export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
|
|
|
895
942
|
samlConfig: safeJsonParse<SAMLConfig>(
|
|
896
943
|
provider.samlConfig as unknown as string,
|
|
897
944
|
),
|
|
898
|
-
redirectURI:
|
|
945
|
+
redirectURI: getOIDCRedirectURI(
|
|
946
|
+
ctx.context.baseURL,
|
|
947
|
+
provider.providerId,
|
|
948
|
+
options,
|
|
949
|
+
),
|
|
899
950
|
...(options?.domainVerification?.enabled ? { domainVerified } : {}),
|
|
900
951
|
...(options?.domainVerification?.enabled
|
|
901
952
|
? { domainVerificationToken }
|
|
@@ -1228,8 +1279,18 @@ export const signInSSO = (options?: SSOOptions) => {
|
|
|
1228
1279
|
message: "Invalid OIDC configuration. Authorization URL not found.",
|
|
1229
1280
|
});
|
|
1230
1281
|
}
|
|
1231
|
-
const state = await generateState(
|
|
1232
|
-
|
|
1282
|
+
const state = await generateState(
|
|
1283
|
+
ctx,
|
|
1284
|
+
undefined,
|
|
1285
|
+
options?.redirectURI?.trim()
|
|
1286
|
+
? { ssoProviderId: provider.providerId }
|
|
1287
|
+
: false,
|
|
1288
|
+
);
|
|
1289
|
+
const redirectURI = getOIDCRedirectURI(
|
|
1290
|
+
ctx.context.baseURL,
|
|
1291
|
+
provider.providerId,
|
|
1292
|
+
options,
|
|
1293
|
+
);
|
|
1233
1294
|
const authorizationURL = await createAuthorizationURL({
|
|
1234
1295
|
id: provider.issuer,
|
|
1235
1296
|
options: {
|
|
@@ -1368,7 +1429,8 @@ export const signInSSO = (options?: SSOOptions) => {
|
|
|
1368
1429
|
const shouldSaveRequest =
|
|
1369
1430
|
loginRequest.id && options?.saml?.enableInResponseToValidation;
|
|
1370
1431
|
if (shouldSaveRequest) {
|
|
1371
|
-
const ttl =
|
|
1432
|
+
const ttl =
|
|
1433
|
+
options?.saml?.requestTTL ?? constants.DEFAULT_AUTHN_REQUEST_TTL_MS;
|
|
1372
1434
|
const record: AuthnRequestRecord = {
|
|
1373
1435
|
id: loginRequest.id,
|
|
1374
1436
|
providerId: provider.providerId,
|
|
@@ -1376,7 +1438,7 @@ export const signInSSO = (options?: SSOOptions) => {
|
|
|
1376
1438
|
expiresAt: Date.now() + ttl,
|
|
1377
1439
|
};
|
|
1378
1440
|
await ctx.context.internalAdapter.createVerificationValue({
|
|
1379
|
-
identifier: `${AUTHN_REQUEST_KEY_PREFIX}${record.id}`,
|
|
1441
|
+
identifier: `${constants.AUTHN_REQUEST_KEY_PREFIX}${record.id}`,
|
|
1380
1442
|
value: JSON.stringify(record),
|
|
1381
1443
|
expiresAt: new Date(record.expiresAt),
|
|
1382
1444
|
});
|
|
@@ -1401,33 +1463,384 @@ const callbackSSOQuerySchema = z.object({
|
|
|
1401
1463
|
error_description: z.string().optional(),
|
|
1402
1464
|
});
|
|
1403
1465
|
|
|
1466
|
+
/**
|
|
1467
|
+
* Core OIDC callback handler logic, shared between the per-provider and
|
|
1468
|
+
* shared callback endpoints. Resolves the provider, exchanges the
|
|
1469
|
+
* authorization code for tokens, and creates a session.
|
|
1470
|
+
*
|
|
1471
|
+
* @param stateData - Pre-parsed state data. If not provided, it will be
|
|
1472
|
+
* parsed from the request context.
|
|
1473
|
+
*/
|
|
1474
|
+
async function handleOIDCCallback(
|
|
1475
|
+
ctx: any,
|
|
1476
|
+
options: SSOOptions | undefined,
|
|
1477
|
+
providerId: string,
|
|
1478
|
+
stateData?: Awaited<ReturnType<typeof parseState>>,
|
|
1479
|
+
) {
|
|
1480
|
+
const { code, error, error_description } = ctx.query;
|
|
1481
|
+
if (!stateData) {
|
|
1482
|
+
stateData = await parseState(ctx);
|
|
1483
|
+
}
|
|
1484
|
+
if (!stateData) {
|
|
1485
|
+
const errorURL =
|
|
1486
|
+
ctx.context.options.onAPIError?.errorURL ||
|
|
1487
|
+
`${ctx.context.baseURL}/error`;
|
|
1488
|
+
throw ctx.redirect(`${errorURL}?error=invalid_state`);
|
|
1489
|
+
}
|
|
1490
|
+
const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
|
|
1491
|
+
if (!code || error) {
|
|
1492
|
+
throw ctx.redirect(
|
|
1493
|
+
`${
|
|
1494
|
+
errorURL || callbackURL
|
|
1495
|
+
}?error=${error}&error_description=${error_description}`,
|
|
1496
|
+
);
|
|
1497
|
+
}
|
|
1498
|
+
let provider: SSOProvider<SSOOptions> | null = null;
|
|
1499
|
+
if (options?.defaultSSO?.length) {
|
|
1500
|
+
const matchingDefault = options.defaultSSO.find(
|
|
1501
|
+
(defaultProvider) => defaultProvider.providerId === providerId,
|
|
1502
|
+
);
|
|
1503
|
+
if (matchingDefault) {
|
|
1504
|
+
provider = {
|
|
1505
|
+
...matchingDefault,
|
|
1506
|
+
issuer: matchingDefault.oidcConfig?.issuer || "",
|
|
1507
|
+
userId: "default",
|
|
1508
|
+
...(options.domainVerification?.enabled
|
|
1509
|
+
? { domainVerified: true }
|
|
1510
|
+
: {}),
|
|
1511
|
+
} as SSOProvider<SSOOptions>;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
if (!provider) {
|
|
1515
|
+
provider = await ctx.context.adapter
|
|
1516
|
+
.findOne({
|
|
1517
|
+
model: "ssoProvider",
|
|
1518
|
+
where: [
|
|
1519
|
+
{
|
|
1520
|
+
field: "providerId",
|
|
1521
|
+
value: providerId,
|
|
1522
|
+
},
|
|
1523
|
+
],
|
|
1524
|
+
})
|
|
1525
|
+
.then((res: { oidcConfig: string } | null) => {
|
|
1526
|
+
if (!res) {
|
|
1527
|
+
return null;
|
|
1528
|
+
}
|
|
1529
|
+
return {
|
|
1530
|
+
...res,
|
|
1531
|
+
oidcConfig: safeJsonParse<OIDCConfig>(res.oidcConfig) || undefined,
|
|
1532
|
+
} as SSOProvider<SSOOptions>;
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
if (!provider) {
|
|
1536
|
+
throw ctx.redirect(
|
|
1537
|
+
`${
|
|
1538
|
+
errorURL || callbackURL
|
|
1539
|
+
}?error=invalid_provider&error_description=provider not found`,
|
|
1540
|
+
);
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
if (
|
|
1544
|
+
options?.domainVerification?.enabled &&
|
|
1545
|
+
!("domainVerified" in provider && provider.domainVerified)
|
|
1546
|
+
) {
|
|
1547
|
+
throw new APIError("UNAUTHORIZED", {
|
|
1548
|
+
message: "Provider domain has not been verified",
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
let config = provider.oidcConfig;
|
|
1553
|
+
|
|
1554
|
+
if (!config) {
|
|
1555
|
+
throw ctx.redirect(
|
|
1556
|
+
`${
|
|
1557
|
+
errorURL || callbackURL
|
|
1558
|
+
}?error=invalid_provider&error_description=provider not found`,
|
|
1559
|
+
);
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
const discovery = await betterFetch<{
|
|
1563
|
+
token_endpoint: string;
|
|
1564
|
+
userinfo_endpoint: string;
|
|
1565
|
+
token_endpoint_auth_method: "client_secret_basic" | "client_secret_post";
|
|
1566
|
+
}>(config.discoveryEndpoint);
|
|
1567
|
+
|
|
1568
|
+
if (discovery.data) {
|
|
1569
|
+
config = {
|
|
1570
|
+
tokenEndpoint: discovery.data.token_endpoint,
|
|
1571
|
+
tokenEndpointAuthentication: discovery.data.token_endpoint_auth_method,
|
|
1572
|
+
userInfoEndpoint: discovery.data.userinfo_endpoint,
|
|
1573
|
+
scopes: ["openid", "email", "profile", "offline_access"],
|
|
1574
|
+
...config,
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
if (!config.tokenEndpoint) {
|
|
1579
|
+
throw ctx.redirect(
|
|
1580
|
+
`${
|
|
1581
|
+
errorURL || callbackURL
|
|
1582
|
+
}?error=invalid_provider&error_description=token_endpoint_not_found`,
|
|
1583
|
+
);
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
const tokenResponse = await validateAuthorizationCode({
|
|
1587
|
+
code,
|
|
1588
|
+
codeVerifier: config.pkce ? stateData.codeVerifier : undefined,
|
|
1589
|
+
redirectURI: getOIDCRedirectURI(
|
|
1590
|
+
ctx.context.baseURL,
|
|
1591
|
+
provider.providerId,
|
|
1592
|
+
options,
|
|
1593
|
+
),
|
|
1594
|
+
options: {
|
|
1595
|
+
clientId: config.clientId,
|
|
1596
|
+
clientSecret: config.clientSecret,
|
|
1597
|
+
},
|
|
1598
|
+
tokenEndpoint: config.tokenEndpoint,
|
|
1599
|
+
authentication:
|
|
1600
|
+
config.tokenEndpointAuthentication === "client_secret_post"
|
|
1601
|
+
? "post"
|
|
1602
|
+
: "basic",
|
|
1603
|
+
}).catch((e) => {
|
|
1604
|
+
if (e instanceof BetterFetchError) {
|
|
1605
|
+
throw ctx.redirect(
|
|
1606
|
+
`${
|
|
1607
|
+
errorURL || callbackURL
|
|
1608
|
+
}?error=invalid_provider&error_description=${e.message}`,
|
|
1609
|
+
);
|
|
1610
|
+
}
|
|
1611
|
+
return null;
|
|
1612
|
+
});
|
|
1613
|
+
if (!tokenResponse) {
|
|
1614
|
+
throw ctx.redirect(
|
|
1615
|
+
`${
|
|
1616
|
+
errorURL || callbackURL
|
|
1617
|
+
}?error=invalid_provider&error_description=token_response_not_found`,
|
|
1618
|
+
);
|
|
1619
|
+
}
|
|
1620
|
+
let userInfo: {
|
|
1621
|
+
id?: string;
|
|
1622
|
+
email?: string;
|
|
1623
|
+
name?: string;
|
|
1624
|
+
image?: string;
|
|
1625
|
+
emailVerified?: boolean;
|
|
1626
|
+
[key: string]: any;
|
|
1627
|
+
} | null = null;
|
|
1628
|
+
if (tokenResponse.idToken) {
|
|
1629
|
+
const idToken = decodeJwt(tokenResponse.idToken);
|
|
1630
|
+
if (!config.jwksEndpoint) {
|
|
1631
|
+
throw ctx.redirect(
|
|
1632
|
+
`${
|
|
1633
|
+
errorURL || callbackURL
|
|
1634
|
+
}?error=invalid_provider&error_description=jwks_endpoint_not_found`,
|
|
1635
|
+
);
|
|
1636
|
+
}
|
|
1637
|
+
const verified = await validateToken(
|
|
1638
|
+
tokenResponse.idToken,
|
|
1639
|
+
config.jwksEndpoint,
|
|
1640
|
+
{
|
|
1641
|
+
audience: config.clientId,
|
|
1642
|
+
issuer: provider.issuer,
|
|
1643
|
+
},
|
|
1644
|
+
).catch((e) => {
|
|
1645
|
+
ctx.context.logger.error(e);
|
|
1646
|
+
return null;
|
|
1647
|
+
});
|
|
1648
|
+
if (!verified) {
|
|
1649
|
+
throw ctx.redirect(
|
|
1650
|
+
`${
|
|
1651
|
+
errorURL || callbackURL
|
|
1652
|
+
}?error=invalid_provider&error_description=token_not_verified`,
|
|
1653
|
+
);
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
const mapping = config.mapping || {};
|
|
1657
|
+
userInfo = {
|
|
1658
|
+
...Object.fromEntries(
|
|
1659
|
+
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
|
|
1660
|
+
key,
|
|
1661
|
+
verified.payload[value],
|
|
1662
|
+
]),
|
|
1663
|
+
),
|
|
1664
|
+
id: idToken[mapping.id || "sub"],
|
|
1665
|
+
email: idToken[mapping.email || "email"],
|
|
1666
|
+
emailVerified: options?.trustEmailVerified
|
|
1667
|
+
? idToken[mapping.emailVerified || "email_verified"]
|
|
1668
|
+
: false,
|
|
1669
|
+
name: idToken[mapping.name || "name"],
|
|
1670
|
+
image: idToken[mapping.image || "picture"],
|
|
1671
|
+
} as {
|
|
1672
|
+
id?: string;
|
|
1673
|
+
email?: string;
|
|
1674
|
+
name?: string;
|
|
1675
|
+
image?: string;
|
|
1676
|
+
emailVerified?: boolean;
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
if (!userInfo) {
|
|
1681
|
+
if (!config.userInfoEndpoint) {
|
|
1682
|
+
throw ctx.redirect(
|
|
1683
|
+
`${
|
|
1684
|
+
errorURL || callbackURL
|
|
1685
|
+
}?error=invalid_provider&error_description=user_info_endpoint_not_found`,
|
|
1686
|
+
);
|
|
1687
|
+
}
|
|
1688
|
+
const userInfoResponse = await betterFetch<{
|
|
1689
|
+
email?: string;
|
|
1690
|
+
name?: string;
|
|
1691
|
+
id?: string;
|
|
1692
|
+
image?: string;
|
|
1693
|
+
emailVerified?: boolean;
|
|
1694
|
+
}>(config.userInfoEndpoint, {
|
|
1695
|
+
headers: {
|
|
1696
|
+
Authorization: `Bearer ${tokenResponse.accessToken}`,
|
|
1697
|
+
},
|
|
1698
|
+
});
|
|
1699
|
+
if (userInfoResponse.error) {
|
|
1700
|
+
throw ctx.redirect(
|
|
1701
|
+
`${errorURL || callbackURL}?error=invalid_provider&error_description=${
|
|
1702
|
+
userInfoResponse.error.message
|
|
1703
|
+
}`,
|
|
1704
|
+
);
|
|
1705
|
+
}
|
|
1706
|
+
userInfo = userInfoResponse.data;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
if (!userInfo.email || !userInfo.id) {
|
|
1710
|
+
throw ctx.redirect(
|
|
1711
|
+
`${
|
|
1712
|
+
errorURL || callbackURL
|
|
1713
|
+
}?error=invalid_provider&error_description=missing_user_info`,
|
|
1714
|
+
);
|
|
1715
|
+
}
|
|
1716
|
+
const isTrustedProvider =
|
|
1717
|
+
"domainVerified" in provider &&
|
|
1718
|
+
(provider as { domainVerified?: boolean }).domainVerified === true &&
|
|
1719
|
+
validateEmailDomain(userInfo.email, provider.domain);
|
|
1720
|
+
|
|
1721
|
+
const linked = await handleOAuthUserInfo(ctx, {
|
|
1722
|
+
userInfo: {
|
|
1723
|
+
email: userInfo.email,
|
|
1724
|
+
name: userInfo.name || "",
|
|
1725
|
+
id: userInfo.id,
|
|
1726
|
+
image: userInfo.image,
|
|
1727
|
+
emailVerified: options?.trustEmailVerified
|
|
1728
|
+
? userInfo.emailVerified || false
|
|
1729
|
+
: false,
|
|
1730
|
+
},
|
|
1731
|
+
account: {
|
|
1732
|
+
idToken: tokenResponse.idToken,
|
|
1733
|
+
accessToken: tokenResponse.accessToken,
|
|
1734
|
+
refreshToken: tokenResponse.refreshToken,
|
|
1735
|
+
accountId: userInfo.id,
|
|
1736
|
+
providerId: provider.providerId,
|
|
1737
|
+
accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
|
|
1738
|
+
refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
|
|
1739
|
+
scope: tokenResponse.scopes?.join(","),
|
|
1740
|
+
},
|
|
1741
|
+
callbackURL,
|
|
1742
|
+
disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
|
|
1743
|
+
overrideUserInfo: config.overrideUserInfo,
|
|
1744
|
+
isTrustedProvider,
|
|
1745
|
+
});
|
|
1746
|
+
if (linked.error) {
|
|
1747
|
+
throw ctx.redirect(`${errorURL || callbackURL}?error=${linked.error}`);
|
|
1748
|
+
}
|
|
1749
|
+
const { session, user } = linked.data!;
|
|
1750
|
+
|
|
1751
|
+
if (options?.provisionUser && linked.isRegister) {
|
|
1752
|
+
await options.provisionUser({
|
|
1753
|
+
user,
|
|
1754
|
+
userInfo,
|
|
1755
|
+
token: tokenResponse,
|
|
1756
|
+
provider,
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
await assignOrganizationFromProvider(ctx as any, {
|
|
1761
|
+
user,
|
|
1762
|
+
profile: {
|
|
1763
|
+
providerType: "oidc",
|
|
1764
|
+
providerId: provider.providerId,
|
|
1765
|
+
accountId: userInfo.id,
|
|
1766
|
+
email: userInfo.email,
|
|
1767
|
+
emailVerified: Boolean(userInfo.emailVerified),
|
|
1768
|
+
rawAttributes: userInfo,
|
|
1769
|
+
},
|
|
1770
|
+
provider,
|
|
1771
|
+
token: tokenResponse,
|
|
1772
|
+
provisioningOptions: options?.organizationProvisioning,
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
await setSessionCookie(ctx, {
|
|
1776
|
+
session,
|
|
1777
|
+
user,
|
|
1778
|
+
});
|
|
1779
|
+
let toRedirectTo: string;
|
|
1780
|
+
try {
|
|
1781
|
+
const url = linked.isRegister ? newUserURL || callbackURL : callbackURL;
|
|
1782
|
+
toRedirectTo = url.toString();
|
|
1783
|
+
} catch {
|
|
1784
|
+
toRedirectTo = linked.isRegister ? newUserURL || callbackURL : callbackURL;
|
|
1785
|
+
}
|
|
1786
|
+
throw ctx.redirect(toRedirectTo);
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
const callbackSSOEndpointConfig = {
|
|
1790
|
+
method: "GET" as const,
|
|
1791
|
+
query: callbackSSOQuerySchema,
|
|
1792
|
+
allowedMediaTypes: [
|
|
1793
|
+
"application/x-www-form-urlencoded",
|
|
1794
|
+
"application/json",
|
|
1795
|
+
] as const,
|
|
1796
|
+
metadata: {
|
|
1797
|
+
...HIDE_METADATA,
|
|
1798
|
+
openapi: {
|
|
1799
|
+
operationId: "handleSSOCallback",
|
|
1800
|
+
summary: "Callback URL for SSO provider",
|
|
1801
|
+
description:
|
|
1802
|
+
"This endpoint is used as the callback URL for SSO providers. It handles the authorization code and exchanges it for an access token",
|
|
1803
|
+
responses: {
|
|
1804
|
+
"302": {
|
|
1805
|
+
description: "Redirects to the callback URL",
|
|
1806
|
+
},
|
|
1807
|
+
},
|
|
1808
|
+
},
|
|
1809
|
+
},
|
|
1810
|
+
};
|
|
1811
|
+
|
|
1404
1812
|
export const callbackSSO = (options?: SSOOptions) => {
|
|
1405
1813
|
return createAuthEndpoint(
|
|
1406
1814
|
"/sso/callback/:providerId",
|
|
1815
|
+
callbackSSOEndpointConfig,
|
|
1816
|
+
async (ctx) => {
|
|
1817
|
+
return handleOIDCCallback(ctx, options, ctx.params.providerId);
|
|
1818
|
+
},
|
|
1819
|
+
);
|
|
1820
|
+
};
|
|
1821
|
+
|
|
1822
|
+
/**
|
|
1823
|
+
* Shared OIDC callback endpoint (no `:providerId` in path).
|
|
1824
|
+
* Used when `options.redirectURI` is set — the `providerId` is read from
|
|
1825
|
+
* the OAuth state instead of the URL path.
|
|
1826
|
+
*/
|
|
1827
|
+
export const callbackSSOShared = (options?: SSOOptions) => {
|
|
1828
|
+
return createAuthEndpoint(
|
|
1829
|
+
"/sso/callback",
|
|
1407
1830
|
{
|
|
1408
|
-
|
|
1409
|
-
query: callbackSSOQuerySchema,
|
|
1410
|
-
allowedMediaTypes: [
|
|
1411
|
-
"application/x-www-form-urlencoded",
|
|
1412
|
-
"application/json",
|
|
1413
|
-
],
|
|
1831
|
+
...callbackSSOEndpointConfig,
|
|
1414
1832
|
metadata: {
|
|
1415
|
-
...
|
|
1833
|
+
...callbackSSOEndpointConfig.metadata,
|
|
1416
1834
|
openapi: {
|
|
1417
|
-
|
|
1418
|
-
|
|
1835
|
+
...callbackSSOEndpointConfig.metadata.openapi,
|
|
1836
|
+
operationId: "handleSSOCallbackShared",
|
|
1837
|
+
summary: "Shared callback URL for all SSO providers",
|
|
1419
1838
|
description:
|
|
1420
|
-
"This endpoint is used as
|
|
1421
|
-
responses: {
|
|
1422
|
-
"302": {
|
|
1423
|
-
description: "Redirects to the callback URL",
|
|
1424
|
-
},
|
|
1425
|
-
},
|
|
1839
|
+
"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.",
|
|
1426
1840
|
},
|
|
1427
1841
|
},
|
|
1428
1842
|
},
|
|
1429
1843
|
async (ctx) => {
|
|
1430
|
-
const { code, error, error_description } = ctx.query;
|
|
1431
1844
|
const stateData = await parseState(ctx);
|
|
1432
1845
|
if (!stateData) {
|
|
1433
1846
|
const errorURL =
|
|
@@ -1435,310 +1848,16 @@ export const callbackSSO = (options?: SSOOptions) => {
|
|
|
1435
1848
|
`${ctx.context.baseURL}/error`;
|
|
1436
1849
|
throw ctx.redirect(`${errorURL}?error=invalid_state`);
|
|
1437
1850
|
}
|
|
1438
|
-
const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
|
|
1439
|
-
if (!code || error) {
|
|
1440
|
-
throw ctx.redirect(
|
|
1441
|
-
`${
|
|
1442
|
-
errorURL || callbackURL
|
|
1443
|
-
}?error=${error}&error_description=${error_description}`,
|
|
1444
|
-
);
|
|
1445
|
-
}
|
|
1446
|
-
let provider: SSOProvider<SSOOptions> | null = null;
|
|
1447
|
-
if (options?.defaultSSO?.length) {
|
|
1448
|
-
const matchingDefault = options.defaultSSO.find(
|
|
1449
|
-
(defaultProvider) =>
|
|
1450
|
-
defaultProvider.providerId === ctx.params.providerId,
|
|
1451
|
-
);
|
|
1452
|
-
if (matchingDefault) {
|
|
1453
|
-
provider = {
|
|
1454
|
-
...matchingDefault,
|
|
1455
|
-
issuer: matchingDefault.oidcConfig?.issuer || "",
|
|
1456
|
-
userId: "default",
|
|
1457
|
-
...(options.domainVerification?.enabled
|
|
1458
|
-
? { domainVerified: true }
|
|
1459
|
-
: {}),
|
|
1460
|
-
} as SSOProvider<SSOOptions>;
|
|
1461
|
-
}
|
|
1462
|
-
}
|
|
1463
|
-
if (!provider) {
|
|
1464
|
-
provider = await ctx.context.adapter
|
|
1465
|
-
.findOne<{
|
|
1466
|
-
oidcConfig: string;
|
|
1467
|
-
}>({
|
|
1468
|
-
model: "ssoProvider",
|
|
1469
|
-
where: [
|
|
1470
|
-
{
|
|
1471
|
-
field: "providerId",
|
|
1472
|
-
value: ctx.params.providerId,
|
|
1473
|
-
},
|
|
1474
|
-
],
|
|
1475
|
-
})
|
|
1476
|
-
.then((res) => {
|
|
1477
|
-
if (!res) {
|
|
1478
|
-
return null;
|
|
1479
|
-
}
|
|
1480
|
-
return {
|
|
1481
|
-
...res,
|
|
1482
|
-
oidcConfig:
|
|
1483
|
-
safeJsonParse<OIDCConfig>(res.oidcConfig) || undefined,
|
|
1484
|
-
} as SSOProvider<SSOOptions>;
|
|
1485
|
-
});
|
|
1486
|
-
}
|
|
1487
|
-
if (!provider) {
|
|
1488
|
-
throw ctx.redirect(
|
|
1489
|
-
`${
|
|
1490
|
-
errorURL || callbackURL
|
|
1491
|
-
}?error=invalid_provider&error_description=provider not found`,
|
|
1492
|
-
);
|
|
1493
|
-
}
|
|
1494
|
-
|
|
1495
|
-
if (
|
|
1496
|
-
options?.domainVerification?.enabled &&
|
|
1497
|
-
!("domainVerified" in provider && provider.domainVerified)
|
|
1498
|
-
) {
|
|
1499
|
-
throw new APIError("UNAUTHORIZED", {
|
|
1500
|
-
message: "Provider domain has not been verified",
|
|
1501
|
-
});
|
|
1502
|
-
}
|
|
1503
|
-
|
|
1504
|
-
let config = provider.oidcConfig;
|
|
1505
|
-
|
|
1506
|
-
if (!config) {
|
|
1507
|
-
throw ctx.redirect(
|
|
1508
|
-
`${
|
|
1509
|
-
errorURL || callbackURL
|
|
1510
|
-
}?error=invalid_provider&error_description=provider not found`,
|
|
1511
|
-
);
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
const discovery = await betterFetch<{
|
|
1515
|
-
token_endpoint: string;
|
|
1516
|
-
userinfo_endpoint: string;
|
|
1517
|
-
token_endpoint_auth_method:
|
|
1518
|
-
| "client_secret_basic"
|
|
1519
|
-
| "client_secret_post";
|
|
1520
|
-
}>(config.discoveryEndpoint);
|
|
1521
|
-
|
|
1522
|
-
if (discovery.data) {
|
|
1523
|
-
config = {
|
|
1524
|
-
tokenEndpoint: discovery.data.token_endpoint,
|
|
1525
|
-
tokenEndpointAuthentication:
|
|
1526
|
-
discovery.data.token_endpoint_auth_method,
|
|
1527
|
-
userInfoEndpoint: discovery.data.userinfo_endpoint,
|
|
1528
|
-
scopes: ["openid", "email", "profile", "offline_access"],
|
|
1529
|
-
...config,
|
|
1530
|
-
};
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
if (!config.tokenEndpoint) {
|
|
1534
|
-
throw ctx.redirect(
|
|
1535
|
-
`${
|
|
1536
|
-
errorURL || callbackURL
|
|
1537
|
-
}?error=invalid_provider&error_description=token_endpoint_not_found`,
|
|
1538
|
-
);
|
|
1539
|
-
}
|
|
1540
|
-
|
|
1541
|
-
const tokenResponse = await validateAuthorizationCode({
|
|
1542
|
-
code,
|
|
1543
|
-
codeVerifier: config.pkce ? stateData.codeVerifier : undefined,
|
|
1544
|
-
redirectURI: `${ctx.context.baseURL}/sso/callback/${provider.providerId}`,
|
|
1545
|
-
options: {
|
|
1546
|
-
clientId: config.clientId,
|
|
1547
|
-
clientSecret: config.clientSecret,
|
|
1548
|
-
},
|
|
1549
|
-
tokenEndpoint: config.tokenEndpoint,
|
|
1550
|
-
authentication:
|
|
1551
|
-
config.tokenEndpointAuthentication === "client_secret_post"
|
|
1552
|
-
? "post"
|
|
1553
|
-
: "basic",
|
|
1554
|
-
}).catch((e) => {
|
|
1555
|
-
if (e instanceof BetterFetchError) {
|
|
1556
|
-
throw ctx.redirect(
|
|
1557
|
-
`${
|
|
1558
|
-
errorURL || callbackURL
|
|
1559
|
-
}?error=invalid_provider&error_description=${e.message}`,
|
|
1560
|
-
);
|
|
1561
|
-
}
|
|
1562
|
-
return null;
|
|
1563
|
-
});
|
|
1564
|
-
if (!tokenResponse) {
|
|
1565
|
-
throw ctx.redirect(
|
|
1566
|
-
`${
|
|
1567
|
-
errorURL || callbackURL
|
|
1568
|
-
}?error=invalid_provider&error_description=token_response_not_found`,
|
|
1569
|
-
);
|
|
1570
|
-
}
|
|
1571
|
-
let userInfo: {
|
|
1572
|
-
id?: string;
|
|
1573
|
-
email?: string;
|
|
1574
|
-
name?: string;
|
|
1575
|
-
image?: string;
|
|
1576
|
-
emailVerified?: boolean;
|
|
1577
|
-
[key: string]: any;
|
|
1578
|
-
} | null = null;
|
|
1579
|
-
if (tokenResponse.idToken) {
|
|
1580
|
-
const idToken = decodeJwt(tokenResponse.idToken);
|
|
1581
|
-
if (!config.jwksEndpoint) {
|
|
1582
|
-
throw ctx.redirect(
|
|
1583
|
-
`${
|
|
1584
|
-
errorURL || callbackURL
|
|
1585
|
-
}?error=invalid_provider&error_description=jwks_endpoint_not_found`,
|
|
1586
|
-
);
|
|
1587
|
-
}
|
|
1588
|
-
const verified = await validateToken(
|
|
1589
|
-
tokenResponse.idToken,
|
|
1590
|
-
config.jwksEndpoint,
|
|
1591
|
-
{
|
|
1592
|
-
audience: config.clientId,
|
|
1593
|
-
issuer: provider.issuer,
|
|
1594
|
-
},
|
|
1595
|
-
).catch((e) => {
|
|
1596
|
-
ctx.context.logger.error(e);
|
|
1597
|
-
return null;
|
|
1598
|
-
});
|
|
1599
|
-
if (!verified) {
|
|
1600
|
-
throw ctx.redirect(
|
|
1601
|
-
`${
|
|
1602
|
-
errorURL || callbackURL
|
|
1603
|
-
}?error=invalid_provider&error_description=token_not_verified`,
|
|
1604
|
-
);
|
|
1605
|
-
}
|
|
1606
|
-
|
|
1607
|
-
const mapping = config.mapping || {};
|
|
1608
|
-
userInfo = {
|
|
1609
|
-
...Object.fromEntries(
|
|
1610
|
-
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
|
|
1611
|
-
key,
|
|
1612
|
-
verified.payload[value],
|
|
1613
|
-
]),
|
|
1614
|
-
),
|
|
1615
|
-
id: idToken[mapping.id || "sub"],
|
|
1616
|
-
email: idToken[mapping.email || "email"],
|
|
1617
|
-
emailVerified: options?.trustEmailVerified
|
|
1618
|
-
? idToken[mapping.emailVerified || "email_verified"]
|
|
1619
|
-
: false,
|
|
1620
|
-
name: idToken[mapping.name || "name"],
|
|
1621
|
-
image: idToken[mapping.image || "picture"],
|
|
1622
|
-
} as {
|
|
1623
|
-
id?: string;
|
|
1624
|
-
email?: string;
|
|
1625
|
-
name?: string;
|
|
1626
|
-
image?: string;
|
|
1627
|
-
emailVerified?: boolean;
|
|
1628
|
-
};
|
|
1629
|
-
}
|
|
1630
1851
|
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
`${
|
|
1635
|
-
errorURL || callbackURL
|
|
1636
|
-
}?error=invalid_provider&error_description=user_info_endpoint_not_found`,
|
|
1637
|
-
);
|
|
1638
|
-
}
|
|
1639
|
-
const userInfoResponse = await betterFetch<{
|
|
1640
|
-
email?: string;
|
|
1641
|
-
name?: string;
|
|
1642
|
-
id?: string;
|
|
1643
|
-
image?: string;
|
|
1644
|
-
emailVerified?: boolean;
|
|
1645
|
-
}>(config.userInfoEndpoint, {
|
|
1646
|
-
headers: {
|
|
1647
|
-
Authorization: `Bearer ${tokenResponse.accessToken}`,
|
|
1648
|
-
},
|
|
1649
|
-
});
|
|
1650
|
-
if (userInfoResponse.error) {
|
|
1651
|
-
throw ctx.redirect(
|
|
1652
|
-
`${
|
|
1653
|
-
errorURL || callbackURL
|
|
1654
|
-
}?error=invalid_provider&error_description=${
|
|
1655
|
-
userInfoResponse.error.message
|
|
1656
|
-
}`,
|
|
1657
|
-
);
|
|
1658
|
-
}
|
|
1659
|
-
userInfo = userInfoResponse.data;
|
|
1660
|
-
}
|
|
1661
|
-
|
|
1662
|
-
if (!userInfo.email || !userInfo.id) {
|
|
1852
|
+
const providerId = stateData.ssoProviderId as string | undefined;
|
|
1853
|
+
if (!providerId) {
|
|
1854
|
+
const errorURL = stateData.errorURL || stateData.callbackURL;
|
|
1663
1855
|
throw ctx.redirect(
|
|
1664
|
-
`${
|
|
1665
|
-
errorURL || callbackURL
|
|
1666
|
-
}?error=invalid_provider&error_description=missing_user_info`,
|
|
1856
|
+
`${errorURL}?error=invalid_state&error_description=missing_provider_id`,
|
|
1667
1857
|
);
|
|
1668
1858
|
}
|
|
1669
|
-
const isTrustedProvider =
|
|
1670
|
-
"domainVerified" in provider &&
|
|
1671
|
-
(provider as { domainVerified?: boolean }).domainVerified === true &&
|
|
1672
|
-
validateEmailDomain(userInfo.email, provider.domain);
|
|
1673
|
-
|
|
1674
|
-
const linked = await handleOAuthUserInfo(ctx, {
|
|
1675
|
-
userInfo: {
|
|
1676
|
-
email: userInfo.email,
|
|
1677
|
-
name: userInfo.name || userInfo.email,
|
|
1678
|
-
id: userInfo.id,
|
|
1679
|
-
image: userInfo.image,
|
|
1680
|
-
emailVerified: options?.trustEmailVerified
|
|
1681
|
-
? userInfo.emailVerified || false
|
|
1682
|
-
: false,
|
|
1683
|
-
},
|
|
1684
|
-
account: {
|
|
1685
|
-
idToken: tokenResponse.idToken,
|
|
1686
|
-
accessToken: tokenResponse.accessToken,
|
|
1687
|
-
refreshToken: tokenResponse.refreshToken,
|
|
1688
|
-
accountId: userInfo.id,
|
|
1689
|
-
providerId: provider.providerId,
|
|
1690
|
-
accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
|
|
1691
|
-
refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
|
|
1692
|
-
scope: tokenResponse.scopes?.join(","),
|
|
1693
|
-
},
|
|
1694
|
-
callbackURL,
|
|
1695
|
-
disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
|
|
1696
|
-
overrideUserInfo: config.overrideUserInfo,
|
|
1697
|
-
isTrustedProvider,
|
|
1698
|
-
});
|
|
1699
|
-
if (linked.error) {
|
|
1700
|
-
throw ctx.redirect(`${errorURL || callbackURL}?error=${linked.error}`);
|
|
1701
|
-
}
|
|
1702
|
-
const { session, user } = linked.data!;
|
|
1703
1859
|
|
|
1704
|
-
|
|
1705
|
-
await options.provisionUser({
|
|
1706
|
-
user,
|
|
1707
|
-
userInfo,
|
|
1708
|
-
token: tokenResponse,
|
|
1709
|
-
provider,
|
|
1710
|
-
});
|
|
1711
|
-
}
|
|
1712
|
-
|
|
1713
|
-
await assignOrganizationFromProvider(ctx as any, {
|
|
1714
|
-
user,
|
|
1715
|
-
profile: {
|
|
1716
|
-
providerType: "oidc",
|
|
1717
|
-
providerId: provider.providerId,
|
|
1718
|
-
accountId: userInfo.id,
|
|
1719
|
-
email: userInfo.email,
|
|
1720
|
-
emailVerified: Boolean(userInfo.emailVerified),
|
|
1721
|
-
rawAttributes: userInfo,
|
|
1722
|
-
},
|
|
1723
|
-
provider,
|
|
1724
|
-
token: tokenResponse,
|
|
1725
|
-
provisioningOptions: options?.organizationProvisioning,
|
|
1726
|
-
});
|
|
1727
|
-
|
|
1728
|
-
await setSessionCookie(ctx, {
|
|
1729
|
-
session,
|
|
1730
|
-
user,
|
|
1731
|
-
});
|
|
1732
|
-
let toRedirectTo: string;
|
|
1733
|
-
try {
|
|
1734
|
-
const url = linked.isRegister ? newUserURL || callbackURL : callbackURL;
|
|
1735
|
-
toRedirectTo = url.toString();
|
|
1736
|
-
} catch {
|
|
1737
|
-
toRedirectTo = linked.isRegister
|
|
1738
|
-
? newUserURL || callbackURL
|
|
1739
|
-
: callbackURL;
|
|
1740
|
-
}
|
|
1741
|
-
throw ctx.redirect(toRedirectTo);
|
|
1860
|
+
return handleOIDCCallback(ctx, options, providerId, stateData);
|
|
1742
1861
|
},
|
|
1743
1862
|
);
|
|
1744
1863
|
};
|
|
@@ -1876,7 +1995,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1876
1995
|
const { SAMLResponse } = ctx.body;
|
|
1877
1996
|
|
|
1878
1997
|
const maxResponseSize =
|
|
1879
|
-
options?.saml?.maxResponseSize ??
|
|
1998
|
+
options?.saml?.maxResponseSize ??
|
|
1999
|
+
constants.DEFAULT_MAX_SAML_RESPONSE_SIZE;
|
|
1880
2000
|
if (new TextEncoder().encode(SAMLResponse).length > maxResponseSize) {
|
|
1881
2001
|
throw new APIError("BAD_REQUEST", {
|
|
1882
2002
|
message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)`,
|
|
@@ -2035,13 +2155,15 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
2035
2155
|
|
|
2036
2156
|
validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
|
|
2037
2157
|
|
|
2038
|
-
validateSAMLTimestamp((extract as
|
|
2158
|
+
validateSAMLTimestamp((extract as SAMLAssertionExtract).conditions, {
|
|
2039
2159
|
clockSkew: options?.saml?.clockSkew,
|
|
2040
2160
|
requireTimestamps: options?.saml?.requireTimestamps,
|
|
2041
2161
|
logger: ctx.context.logger,
|
|
2042
2162
|
});
|
|
2043
2163
|
|
|
2044
|
-
const inResponseTo = (extract as
|
|
2164
|
+
const inResponseTo = (extract as SAMLAssertionExtract).inResponseTo as
|
|
2165
|
+
| string
|
|
2166
|
+
| undefined;
|
|
2045
2167
|
const shouldValidateInResponseTo =
|
|
2046
2168
|
options?.saml?.enableInResponseToValidation;
|
|
2047
2169
|
|
|
@@ -2053,7 +2175,7 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
2053
2175
|
|
|
2054
2176
|
const verification =
|
|
2055
2177
|
await ctx.context.internalAdapter.findVerificationValue(
|
|
2056
|
-
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
|
2178
|
+
`${constants.AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
|
2057
2179
|
);
|
|
2058
2180
|
if (verification) {
|
|
2059
2181
|
try {
|
|
@@ -2093,7 +2215,7 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
2093
2215
|
);
|
|
2094
2216
|
|
|
2095
2217
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
2096
|
-
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
|
2218
|
+
`${constants.AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
|
2097
2219
|
);
|
|
2098
2220
|
const redirectUrl =
|
|
2099
2221
|
relayState?.callbackURL ||
|
|
@@ -2105,7 +2227,7 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
2105
2227
|
}
|
|
2106
2228
|
|
|
2107
2229
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
2108
|
-
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
|
2230
|
+
`${constants.AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
|
2109
2231
|
);
|
|
2110
2232
|
} else if (!allowIdpInitiated) {
|
|
2111
2233
|
ctx.context.logger.error(
|
|
@@ -2130,17 +2252,18 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
2130
2252
|
|
|
2131
2253
|
if (assertionId) {
|
|
2132
2254
|
const issuer = idp.entityMeta.getEntityID();
|
|
2133
|
-
const conditions = (extract as
|
|
2255
|
+
const conditions = (extract as SAMLAssertionExtract).conditions as
|
|
2134
2256
|
| SAMLConditions
|
|
2135
2257
|
| undefined;
|
|
2136
|
-
const clockSkew =
|
|
2258
|
+
const clockSkew =
|
|
2259
|
+
options?.saml?.clockSkew ?? constants.DEFAULT_CLOCK_SKEW_MS;
|
|
2137
2260
|
const expiresAt = conditions?.notOnOrAfter
|
|
2138
2261
|
? new Date(conditions.notOnOrAfter).getTime() + clockSkew
|
|
2139
|
-
: Date.now() + DEFAULT_ASSERTION_TTL_MS;
|
|
2262
|
+
: Date.now() + constants.DEFAULT_ASSERTION_TTL_MS;
|
|
2140
2263
|
|
|
2141
2264
|
const existingAssertion =
|
|
2142
2265
|
await ctx.context.internalAdapter.findVerificationValue(
|
|
2143
|
-
`${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
|
|
2266
|
+
`${constants.USED_ASSERTION_KEY_PREFIX}${assertionId}`,
|
|
2144
2267
|
);
|
|
2145
2268
|
|
|
2146
2269
|
let isReplay = false;
|
|
@@ -2177,7 +2300,7 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
2177
2300
|
}
|
|
2178
2301
|
|
|
2179
2302
|
await ctx.context.internalAdapter.createVerificationValue({
|
|
2180
|
-
identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
|
|
2303
|
+
identifier: `${constants.USED_ASSERTION_KEY_PREFIX}${assertionId}`,
|
|
2181
2304
|
value: JSON.stringify({
|
|
2182
2305
|
assertionId,
|
|
2183
2306
|
issuer,
|
|
@@ -2300,6 +2423,39 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
2300
2423
|
|
|
2301
2424
|
await setSessionCookie(ctx, { session, user });
|
|
2302
2425
|
|
|
2426
|
+
if (options?.saml?.enableSingleLogout && extract.nameID) {
|
|
2427
|
+
const samlSessionKey = `${constants.SAML_SESSION_KEY_PREFIX}${provider.providerId}:${extract.nameID}`;
|
|
2428
|
+
const samlSessionData: SAMLSessionRecord = {
|
|
2429
|
+
sessionId: session.id,
|
|
2430
|
+
providerId: provider.providerId,
|
|
2431
|
+
nameID: extract.nameID,
|
|
2432
|
+
sessionIndex: (extract as SAMLAssertionExtract).sessionIndex,
|
|
2433
|
+
};
|
|
2434
|
+
await ctx.context.internalAdapter
|
|
2435
|
+
.createVerificationValue({
|
|
2436
|
+
identifier: samlSessionKey,
|
|
2437
|
+
value: JSON.stringify(samlSessionData),
|
|
2438
|
+
expiresAt: session.expiresAt,
|
|
2439
|
+
})
|
|
2440
|
+
.catch((e) =>
|
|
2441
|
+
ctx.context.logger.warn("Failed to create SAML session record", {
|
|
2442
|
+
error: e,
|
|
2443
|
+
}),
|
|
2444
|
+
);
|
|
2445
|
+
await ctx.context.internalAdapter
|
|
2446
|
+
.createVerificationValue({
|
|
2447
|
+
identifier: `${constants.SAML_SESSION_BY_ID_PREFIX}${session.id}`,
|
|
2448
|
+
value: samlSessionKey,
|
|
2449
|
+
expiresAt: session.expiresAt,
|
|
2450
|
+
})
|
|
2451
|
+
.catch((e) =>
|
|
2452
|
+
ctx.context.logger.warn(
|
|
2453
|
+
"Failed to create SAML session lookup record",
|
|
2454
|
+
e,
|
|
2455
|
+
),
|
|
2456
|
+
);
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2303
2459
|
const safeRedirectUrl = getSafeRedirectUrl(
|
|
2304
2460
|
relayState?.callbackURL || parsedSamlConfig.callbackUrl,
|
|
2305
2461
|
currentCallbackPath,
|
|
@@ -2349,7 +2505,8 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2349
2505
|
const appOrigin = new URL(ctx.context.baseURL).origin;
|
|
2350
2506
|
|
|
2351
2507
|
const maxResponseSize =
|
|
2352
|
-
options?.saml?.maxResponseSize ??
|
|
2508
|
+
options?.saml?.maxResponseSize ??
|
|
2509
|
+
constants.DEFAULT_MAX_SAML_RESPONSE_SIZE;
|
|
2353
2510
|
if (new TextEncoder().encode(SAMLResponse).length > maxResponseSize) {
|
|
2354
2511
|
throw new APIError("BAD_REQUEST", {
|
|
2355
2512
|
message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)`,
|
|
@@ -2516,13 +2673,13 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2516
2673
|
|
|
2517
2674
|
validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
|
|
2518
2675
|
|
|
2519
|
-
validateSAMLTimestamp((extract as
|
|
2676
|
+
validateSAMLTimestamp((extract as SAMLAssertionExtract).conditions, {
|
|
2520
2677
|
clockSkew: options?.saml?.clockSkew,
|
|
2521
2678
|
requireTimestamps: options?.saml?.requireTimestamps,
|
|
2522
2679
|
logger: ctx.context.logger,
|
|
2523
2680
|
});
|
|
2524
2681
|
|
|
2525
|
-
const inResponseToAcs = (extract as
|
|
2682
|
+
const inResponseToAcs = (extract as SAMLAssertionExtract).inResponseTo as
|
|
2526
2683
|
| string
|
|
2527
2684
|
| undefined;
|
|
2528
2685
|
const shouldValidateInResponseToAcs =
|
|
@@ -2536,7 +2693,7 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2536
2693
|
|
|
2537
2694
|
const verification =
|
|
2538
2695
|
await ctx.context.internalAdapter.findVerificationValue(
|
|
2539
|
-
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
|
|
2696
|
+
`${constants.AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
|
|
2540
2697
|
);
|
|
2541
2698
|
if (verification) {
|
|
2542
2699
|
try {
|
|
@@ -2575,7 +2732,7 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2575
2732
|
},
|
|
2576
2733
|
);
|
|
2577
2734
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
2578
|
-
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
|
|
2735
|
+
`${constants.AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
|
|
2579
2736
|
);
|
|
2580
2737
|
const redirectUrl =
|
|
2581
2738
|
relayState?.callbackURL ||
|
|
@@ -2587,7 +2744,7 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2587
2744
|
}
|
|
2588
2745
|
|
|
2589
2746
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
2590
|
-
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
|
|
2747
|
+
`${constants.AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
|
|
2591
2748
|
);
|
|
2592
2749
|
} else if (!allowIdpInitiated) {
|
|
2593
2750
|
ctx.context.logger.error(
|
|
@@ -2612,17 +2769,18 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2612
2769
|
|
|
2613
2770
|
if (assertionIdAcs) {
|
|
2614
2771
|
const issuer = idp.entityMeta.getEntityID();
|
|
2615
|
-
const conditions = (extract as
|
|
2772
|
+
const conditions = (extract as SAMLAssertionExtract).conditions as
|
|
2616
2773
|
| SAMLConditions
|
|
2617
2774
|
| undefined;
|
|
2618
|
-
const clockSkew =
|
|
2775
|
+
const clockSkew =
|
|
2776
|
+
options?.saml?.clockSkew ?? constants.DEFAULT_CLOCK_SKEW_MS;
|
|
2619
2777
|
const expiresAt = conditions?.notOnOrAfter
|
|
2620
2778
|
? new Date(conditions.notOnOrAfter).getTime() + clockSkew
|
|
2621
|
-
: Date.now() + DEFAULT_ASSERTION_TTL_MS;
|
|
2779
|
+
: Date.now() + constants.DEFAULT_ASSERTION_TTL_MS;
|
|
2622
2780
|
|
|
2623
2781
|
const existingAssertion =
|
|
2624
2782
|
await ctx.context.internalAdapter.findVerificationValue(
|
|
2625
|
-
`${USED_ASSERTION_KEY_PREFIX}${assertionIdAcs}`,
|
|
2783
|
+
`${constants.USED_ASSERTION_KEY_PREFIX}${assertionIdAcs}`,
|
|
2626
2784
|
);
|
|
2627
2785
|
|
|
2628
2786
|
let isReplay = false;
|
|
@@ -2659,7 +2817,7 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2659
2817
|
}
|
|
2660
2818
|
|
|
2661
2819
|
await ctx.context.internalAdapter.createVerificationValue({
|
|
2662
|
-
identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionIdAcs}`,
|
|
2820
|
+
identifier: `${constants.USED_ASSERTION_KEY_PREFIX}${assertionIdAcs}`,
|
|
2663
2821
|
value: JSON.stringify({
|
|
2664
2822
|
assertionId: assertionIdAcs,
|
|
2665
2823
|
issuer,
|
|
@@ -2782,6 +2940,39 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2782
2940
|
});
|
|
2783
2941
|
|
|
2784
2942
|
await setSessionCookie(ctx, { session, user });
|
|
2943
|
+
if (options?.saml?.enableSingleLogout && extract.nameID) {
|
|
2944
|
+
const samlSessionKey = `${constants.SAML_SESSION_KEY_PREFIX}${providerId}:${extract.nameID}`;
|
|
2945
|
+
const samlSessionData: SAMLSessionRecord = {
|
|
2946
|
+
sessionId: session.id,
|
|
2947
|
+
providerId,
|
|
2948
|
+
nameID: extract.nameID,
|
|
2949
|
+
sessionIndex: (extract as SAMLAssertionExtract).sessionIndex,
|
|
2950
|
+
};
|
|
2951
|
+
await ctx.context.internalAdapter
|
|
2952
|
+
.createVerificationValue({
|
|
2953
|
+
identifier: samlSessionKey,
|
|
2954
|
+
value: JSON.stringify(samlSessionData),
|
|
2955
|
+
expiresAt: session.expiresAt,
|
|
2956
|
+
})
|
|
2957
|
+
.catch((e) =>
|
|
2958
|
+
ctx.context.logger.warn("Failed to create SAML session record", {
|
|
2959
|
+
error: e,
|
|
2960
|
+
}),
|
|
2961
|
+
);
|
|
2962
|
+
await ctx.context.internalAdapter
|
|
2963
|
+
.createVerificationValue({
|
|
2964
|
+
identifier: `${constants.SAML_SESSION_BY_ID_PREFIX}${session.id}`,
|
|
2965
|
+
value: samlSessionKey,
|
|
2966
|
+
expiresAt: session.expiresAt,
|
|
2967
|
+
})
|
|
2968
|
+
.catch((e) =>
|
|
2969
|
+
ctx.context.logger.warn(
|
|
2970
|
+
"Failed to create SAML session lookup record",
|
|
2971
|
+
e,
|
|
2972
|
+
),
|
|
2973
|
+
);
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2785
2976
|
const safeRedirectUrl = getSafeRedirectUrl(
|
|
2786
2977
|
relayState?.callbackURL || parsedSamlConfig.callbackUrl,
|
|
2787
2978
|
currentCallbackPath,
|
|
@@ -2792,3 +2983,383 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2792
2983
|
},
|
|
2793
2984
|
);
|
|
2794
2985
|
};
|
|
2986
|
+
|
|
2987
|
+
const sloSchema = z.object({
|
|
2988
|
+
SAMLRequest: z.string().optional(),
|
|
2989
|
+
SAMLResponse: z.string().optional(),
|
|
2990
|
+
RelayState: z.string().optional(),
|
|
2991
|
+
SigAlg: z.string().optional(),
|
|
2992
|
+
Signature: z.string().optional(),
|
|
2993
|
+
});
|
|
2994
|
+
|
|
2995
|
+
export const sloEndpoint = (options?: SSOOptions) => {
|
|
2996
|
+
return createAuthEndpoint(
|
|
2997
|
+
"/sso/saml2/sp/slo/:providerId",
|
|
2998
|
+
{
|
|
2999
|
+
method: ["GET", "POST"],
|
|
3000
|
+
body: sloSchema.optional(),
|
|
3001
|
+
query: sloSchema.optional(),
|
|
3002
|
+
metadata: {
|
|
3003
|
+
...HIDE_METADATA,
|
|
3004
|
+
allowedMediaTypes: [
|
|
3005
|
+
"application/x-www-form-urlencoded",
|
|
3006
|
+
"application/json",
|
|
3007
|
+
],
|
|
3008
|
+
},
|
|
3009
|
+
},
|
|
3010
|
+
async (ctx) => {
|
|
3011
|
+
if (!options?.saml?.enableSingleLogout) {
|
|
3012
|
+
throw APIError.from(
|
|
3013
|
+
"BAD_REQUEST",
|
|
3014
|
+
SAML_ERROR_CODES.SINGLE_LOGOUT_NOT_ENABLED,
|
|
3015
|
+
);
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
const { providerId } = ctx.params;
|
|
3019
|
+
|
|
3020
|
+
const samlRequest = ctx.body?.SAMLRequest || ctx.query?.SAMLRequest;
|
|
3021
|
+
const samlResponse = ctx.body?.SAMLResponse || ctx.query?.SAMLResponse;
|
|
3022
|
+
const relayState = ctx.body?.RelayState || ctx.query?.RelayState;
|
|
3023
|
+
const appOrigin = new URL(ctx.context.baseURL).origin;
|
|
3024
|
+
const safeErrorURL = getSafeRedirectUrl(
|
|
3025
|
+
relayState,
|
|
3026
|
+
`${appOrigin}/sso/saml2/sp/slo/${providerId}`,
|
|
3027
|
+
appOrigin,
|
|
3028
|
+
(url, settings) => ctx.context.isTrustedOrigin(url, settings),
|
|
3029
|
+
);
|
|
3030
|
+
|
|
3031
|
+
if (!samlRequest && !samlResponse) {
|
|
3032
|
+
throw ctx.redirect(
|
|
3033
|
+
`${safeErrorURL}?error=invalid_request&error_description=missing_logout_data`,
|
|
3034
|
+
);
|
|
3035
|
+
}
|
|
3036
|
+
|
|
3037
|
+
const provider = await findSAMLProvider(
|
|
3038
|
+
providerId,
|
|
3039
|
+
options,
|
|
3040
|
+
ctx.context.adapter,
|
|
3041
|
+
);
|
|
3042
|
+
if (!provider?.samlConfig) {
|
|
3043
|
+
throw APIError.from(
|
|
3044
|
+
"NOT_FOUND",
|
|
3045
|
+
SAML_ERROR_CODES.SAML_PROVIDER_NOT_FOUND,
|
|
3046
|
+
);
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
const config = provider.samlConfig as SAMLConfig;
|
|
3050
|
+
const sp = createSP(config, ctx.context.baseURL, providerId, {
|
|
3051
|
+
wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
|
|
3052
|
+
wantLogoutResponseSigned: options?.saml?.wantLogoutResponseSigned,
|
|
3053
|
+
});
|
|
3054
|
+
const idp = createIdP(config);
|
|
3055
|
+
|
|
3056
|
+
if (samlResponse) {
|
|
3057
|
+
return handleLogoutResponse(ctx, sp, idp, relayState, providerId);
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
return handleLogoutRequest(ctx, sp, idp, relayState, providerId);
|
|
3061
|
+
},
|
|
3062
|
+
);
|
|
3063
|
+
};
|
|
3064
|
+
|
|
3065
|
+
async function handleLogoutResponse(
|
|
3066
|
+
ctx: any,
|
|
3067
|
+
sp: ReturnType<typeof createSP>,
|
|
3068
|
+
idp: ReturnType<typeof createIdP>,
|
|
3069
|
+
relayState: string | undefined,
|
|
3070
|
+
providerId: string,
|
|
3071
|
+
) {
|
|
3072
|
+
const binding =
|
|
3073
|
+
ctx.method === "POST" && ctx.body?.SAMLResponse ? "post" : "redirect";
|
|
3074
|
+
|
|
3075
|
+
let parsed: Awaited<ReturnType<typeof sp.parseLogoutResponse>> | undefined;
|
|
3076
|
+
try {
|
|
3077
|
+
parsed = await sp.parseLogoutResponse(idp, binding, {
|
|
3078
|
+
body: ctx.body,
|
|
3079
|
+
query: ctx.query,
|
|
3080
|
+
});
|
|
3081
|
+
} catch (error) {
|
|
3082
|
+
ctx.context.logger.error("LogoutResponse validation failed", { error });
|
|
3083
|
+
throw APIError.from(
|
|
3084
|
+
"BAD_REQUEST",
|
|
3085
|
+
SAML_ERROR_CODES.INVALID_LOGOUT_RESPONSE,
|
|
3086
|
+
);
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
const extract = parsed?.extract as {
|
|
3090
|
+
response?: { inResponseTo?: string };
|
|
3091
|
+
status?: string;
|
|
3092
|
+
statusCode?: string;
|
|
3093
|
+
};
|
|
3094
|
+
|
|
3095
|
+
const statusCode =
|
|
3096
|
+
extract?.statusCode ||
|
|
3097
|
+
extract?.status ||
|
|
3098
|
+
(parsed as any)?.samlContent?.status?.statusCode;
|
|
3099
|
+
if (statusCode && statusCode !== constants.SAML_STATUS_SUCCESS) {
|
|
3100
|
+
ctx.context.logger.warn("LogoutResponse indicates failure", { statusCode });
|
|
3101
|
+
throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.LOGOUT_FAILED_AT_IDP);
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
const inResponseTo = extract?.response?.inResponseTo;
|
|
3105
|
+
if (inResponseTo) {
|
|
3106
|
+
const key = `${constants.LOGOUT_REQUEST_KEY_PREFIX}${inResponseTo}`;
|
|
3107
|
+
const pendingRequest =
|
|
3108
|
+
await ctx.context.internalAdapter.findVerificationValue(key);
|
|
3109
|
+
|
|
3110
|
+
if (!pendingRequest) {
|
|
3111
|
+
ctx.context.logger.warn(
|
|
3112
|
+
"LogoutResponse references unknown or expired request",
|
|
3113
|
+
{ inResponseTo },
|
|
3114
|
+
);
|
|
3115
|
+
}
|
|
3116
|
+
|
|
3117
|
+
await ctx.context.internalAdapter
|
|
3118
|
+
.deleteVerificationValue(key)
|
|
3119
|
+
.catch((e: unknown) =>
|
|
3120
|
+
ctx.context.logger.warn(
|
|
3121
|
+
"Failed to delete logout request verification value",
|
|
3122
|
+
e,
|
|
3123
|
+
),
|
|
3124
|
+
);
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
deleteSessionCookie(ctx);
|
|
3128
|
+
|
|
3129
|
+
const appOrigin = new URL(ctx.context.baseURL).origin;
|
|
3130
|
+
const safeRedirectUrl = getSafeRedirectUrl(
|
|
3131
|
+
relayState,
|
|
3132
|
+
`${appOrigin}/sso/saml2/sp/slo/${providerId}`,
|
|
3133
|
+
appOrigin,
|
|
3134
|
+
(url, settings) => ctx.context.isTrustedOrigin(url, settings),
|
|
3135
|
+
);
|
|
3136
|
+
throw ctx.redirect(safeRedirectUrl);
|
|
3137
|
+
}
|
|
3138
|
+
|
|
3139
|
+
async function handleLogoutRequest(
|
|
3140
|
+
ctx: any,
|
|
3141
|
+
sp: ReturnType<typeof createSP>,
|
|
3142
|
+
idp: ReturnType<typeof createIdP>,
|
|
3143
|
+
relayState: string | undefined,
|
|
3144
|
+
providerId: string,
|
|
3145
|
+
) {
|
|
3146
|
+
const binding =
|
|
3147
|
+
ctx.method === "POST" && ctx.body?.SAMLRequest ? "post" : "redirect";
|
|
3148
|
+
|
|
3149
|
+
let parsed: Awaited<ReturnType<typeof sp.parseLogoutRequest>> | undefined;
|
|
3150
|
+
try {
|
|
3151
|
+
parsed = await sp.parseLogoutRequest(idp, binding, {
|
|
3152
|
+
body: ctx.body,
|
|
3153
|
+
query: ctx.query,
|
|
3154
|
+
});
|
|
3155
|
+
} catch (error) {
|
|
3156
|
+
ctx.context.logger.error("LogoutRequest validation failed", { error });
|
|
3157
|
+
throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.INVALID_LOGOUT_REQUEST);
|
|
3158
|
+
}
|
|
3159
|
+
if (!parsed?.extract) {
|
|
3160
|
+
throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.INVALID_LOGOUT_REQUEST);
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
const { nameID } = parsed.extract;
|
|
3164
|
+
const sessionIndex = (parsed.extract as SAMLAssertionExtract).sessionIndex;
|
|
3165
|
+
|
|
3166
|
+
const key = `${constants.SAML_SESSION_KEY_PREFIX}${providerId}:${nameID}`;
|
|
3167
|
+
const stored = await ctx.context.internalAdapter.findVerificationValue(key);
|
|
3168
|
+
|
|
3169
|
+
if (stored) {
|
|
3170
|
+
const data = safeJsonParse<SAMLSessionRecord>(stored.value);
|
|
3171
|
+
if (data) {
|
|
3172
|
+
if (
|
|
3173
|
+
!sessionIndex ||
|
|
3174
|
+
!data.sessionIndex ||
|
|
3175
|
+
sessionIndex === data.sessionIndex
|
|
3176
|
+
) {
|
|
3177
|
+
await ctx.context.internalAdapter
|
|
3178
|
+
.deleteSession(data.sessionId)
|
|
3179
|
+
.catch((e: unknown) =>
|
|
3180
|
+
ctx.context.logger.warn("Failed to delete session during SLO", {
|
|
3181
|
+
error: e,
|
|
3182
|
+
}),
|
|
3183
|
+
);
|
|
3184
|
+
await ctx.context.internalAdapter
|
|
3185
|
+
.deleteVerificationValue(
|
|
3186
|
+
`${constants.SAML_SESSION_BY_ID_PREFIX}${data.sessionId}`,
|
|
3187
|
+
)
|
|
3188
|
+
.catch((e: unknown) =>
|
|
3189
|
+
ctx.context.logger.warn(
|
|
3190
|
+
"Failed to delete SAML session lookup during SLO",
|
|
3191
|
+
e,
|
|
3192
|
+
),
|
|
3193
|
+
);
|
|
3194
|
+
} else {
|
|
3195
|
+
ctx.context.logger.warn(
|
|
3196
|
+
"SessionIndex mismatch in LogoutRequest - skipping session deletion",
|
|
3197
|
+
{
|
|
3198
|
+
providerId,
|
|
3199
|
+
requestedSessionIndex: sessionIndex,
|
|
3200
|
+
storedSessionIndex: data.sessionIndex,
|
|
3201
|
+
},
|
|
3202
|
+
);
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3205
|
+
await ctx.context.internalAdapter
|
|
3206
|
+
.deleteVerificationValue(key)
|
|
3207
|
+
.catch((e: unknown) =>
|
|
3208
|
+
ctx.context.logger.warn(
|
|
3209
|
+
"Failed to delete SAML session key during SLO",
|
|
3210
|
+
e,
|
|
3211
|
+
),
|
|
3212
|
+
);
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3215
|
+
const currentSession = await getSessionFromCtx(ctx);
|
|
3216
|
+
if (currentSession?.session) {
|
|
3217
|
+
await ctx.context.internalAdapter.deleteSession(currentSession.session.id);
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
deleteSessionCookie(ctx);
|
|
3221
|
+
|
|
3222
|
+
const requestId = parsed.extract.request?.id || "";
|
|
3223
|
+
const res = sp.createLogoutResponse(
|
|
3224
|
+
idp,
|
|
3225
|
+
null,
|
|
3226
|
+
binding,
|
|
3227
|
+
relayState || "",
|
|
3228
|
+
(template: string) =>
|
|
3229
|
+
template
|
|
3230
|
+
.replace("{InResponseTo}", requestId)
|
|
3231
|
+
.replace("{StatusCode}", constants.SAML_STATUS_SUCCESS),
|
|
3232
|
+
) as { context: string; entityEndpoint?: string };
|
|
3233
|
+
|
|
3234
|
+
if (binding === "post" && res.entityEndpoint) {
|
|
3235
|
+
return createSAMLPostForm(
|
|
3236
|
+
res.entityEndpoint,
|
|
3237
|
+
"SAMLResponse",
|
|
3238
|
+
res.context,
|
|
3239
|
+
relayState,
|
|
3240
|
+
);
|
|
3241
|
+
}
|
|
3242
|
+
throw ctx.redirect(res.context);
|
|
3243
|
+
}
|
|
3244
|
+
|
|
3245
|
+
export const initiateSLO = (options?: SSOOptions) => {
|
|
3246
|
+
return createAuthEndpoint(
|
|
3247
|
+
"/sso/saml2/logout/:providerId",
|
|
3248
|
+
{
|
|
3249
|
+
method: "POST",
|
|
3250
|
+
body: z.object({
|
|
3251
|
+
callbackURL: z.string().optional(),
|
|
3252
|
+
}),
|
|
3253
|
+
use: [sessionMiddleware],
|
|
3254
|
+
metadata: HIDE_METADATA,
|
|
3255
|
+
},
|
|
3256
|
+
async (ctx) => {
|
|
3257
|
+
if (!options?.saml?.enableSingleLogout) {
|
|
3258
|
+
throw APIError.from(
|
|
3259
|
+
"BAD_REQUEST",
|
|
3260
|
+
SAML_ERROR_CODES.SINGLE_LOGOUT_NOT_ENABLED,
|
|
3261
|
+
);
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
const { providerId } = ctx.params;
|
|
3265
|
+
const callbackURL = ctx.body.callbackURL || ctx.context.baseURL;
|
|
3266
|
+
|
|
3267
|
+
const provider = await findSAMLProvider(
|
|
3268
|
+
providerId,
|
|
3269
|
+
options,
|
|
3270
|
+
ctx.context.adapter,
|
|
3271
|
+
);
|
|
3272
|
+
if (!provider?.samlConfig) {
|
|
3273
|
+
throw APIError.from(
|
|
3274
|
+
"NOT_FOUND",
|
|
3275
|
+
SAML_ERROR_CODES.SAML_PROVIDER_NOT_FOUND,
|
|
3276
|
+
);
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
const config = provider.samlConfig as SAMLConfig;
|
|
3280
|
+
|
|
3281
|
+
const idpHasSLO =
|
|
3282
|
+
config.idpMetadata?.singleLogoutService?.length ||
|
|
3283
|
+
(config.idpMetadata?.metadata &&
|
|
3284
|
+
config.idpMetadata.metadata.includes("SingleLogoutService"));
|
|
3285
|
+
if (!idpHasSLO) {
|
|
3286
|
+
throw APIError.from(
|
|
3287
|
+
"BAD_REQUEST",
|
|
3288
|
+
SAML_ERROR_CODES.IDP_SLO_NOT_SUPPORTED,
|
|
3289
|
+
);
|
|
3290
|
+
}
|
|
3291
|
+
|
|
3292
|
+
const sp = createSP(config, ctx.context.baseURL, providerId, {
|
|
3293
|
+
wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
|
|
3294
|
+
wantLogoutResponseSigned: options?.saml?.wantLogoutResponseSigned,
|
|
3295
|
+
});
|
|
3296
|
+
const idp = createIdP(config);
|
|
3297
|
+
|
|
3298
|
+
const session = ctx.context.session;
|
|
3299
|
+
const sessionLookupKey = `${constants.SAML_SESSION_BY_ID_PREFIX}${session.session.id}`;
|
|
3300
|
+
const sessionLookup =
|
|
3301
|
+
await ctx.context.internalAdapter.findVerificationValue(
|
|
3302
|
+
sessionLookupKey,
|
|
3303
|
+
);
|
|
3304
|
+
|
|
3305
|
+
let nameID = session.user.email;
|
|
3306
|
+
let sessionIndex: string | undefined;
|
|
3307
|
+
let samlSessionKey: string | undefined;
|
|
3308
|
+
|
|
3309
|
+
if (sessionLookup) {
|
|
3310
|
+
samlSessionKey = sessionLookup.value;
|
|
3311
|
+
const stored =
|
|
3312
|
+
await ctx.context.internalAdapter.findVerificationValue(
|
|
3313
|
+
samlSessionKey,
|
|
3314
|
+
);
|
|
3315
|
+
if (stored) {
|
|
3316
|
+
const data = safeJsonParse<SAMLSessionRecord>(stored.value);
|
|
3317
|
+
if (data) {
|
|
3318
|
+
nameID = data.nameID || nameID;
|
|
3319
|
+
sessionIndex = data.sessionIndex;
|
|
3320
|
+
}
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3324
|
+
const logoutRequest = sp.createLogoutRequest(idp, "redirect", {
|
|
3325
|
+
logoutNameID: nameID,
|
|
3326
|
+
sessionIndex,
|
|
3327
|
+
relayState: callbackURL,
|
|
3328
|
+
}) as { id: string; context: string };
|
|
3329
|
+
|
|
3330
|
+
const ttl =
|
|
3331
|
+
options?.saml?.logoutRequestTTL ??
|
|
3332
|
+
constants.DEFAULT_LOGOUT_REQUEST_TTL_MS;
|
|
3333
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
3334
|
+
identifier: `${constants.LOGOUT_REQUEST_KEY_PREFIX}${logoutRequest.id}`,
|
|
3335
|
+
value: providerId,
|
|
3336
|
+
expiresAt: new Date(Date.now() + ttl),
|
|
3337
|
+
});
|
|
3338
|
+
|
|
3339
|
+
if (samlSessionKey) {
|
|
3340
|
+
await ctx.context.internalAdapter
|
|
3341
|
+
.deleteVerificationValue(samlSessionKey)
|
|
3342
|
+
.catch((e) =>
|
|
3343
|
+
ctx.context.logger.warn(
|
|
3344
|
+
"Failed to delete SAML session key during logout",
|
|
3345
|
+
e,
|
|
3346
|
+
),
|
|
3347
|
+
);
|
|
3348
|
+
}
|
|
3349
|
+
await ctx.context.internalAdapter
|
|
3350
|
+
.deleteVerificationValue(sessionLookupKey)
|
|
3351
|
+
.catch((e) =>
|
|
3352
|
+
ctx.context.logger.warn(
|
|
3353
|
+
"Failed to delete session lookup key during logout",
|
|
3354
|
+
e,
|
|
3355
|
+
),
|
|
3356
|
+
);
|
|
3357
|
+
|
|
3358
|
+
await ctx.context.internalAdapter.deleteSession(session.session.id);
|
|
3359
|
+
|
|
3360
|
+
deleteSessionCookie(ctx);
|
|
3361
|
+
|
|
3362
|
+
throw ctx.redirect(logoutRequest.context);
|
|
3363
|
+
},
|
|
3364
|
+
);
|
|
3365
|
+
};
|