@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/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 { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "../types";
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 ?? DEFAULT_MAX_SAML_METADATA_SIZE;
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.domainVerification?.tokenPrefix
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: `${ctx.context.baseURL}/sso/callback/${provider.providerId}`,
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(ctx, undefined, false);
1232
- const redirectURI = `${ctx.context.baseURL}/sso/callback/${provider.providerId}`;
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 = options?.saml?.requestTTL ?? DEFAULT_AUTHN_REQUEST_TTL_MS;
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
- method: "GET",
1409
- query: callbackSSOQuerySchema,
1410
- allowedMediaTypes: [
1411
- "application/x-www-form-urlencoded",
1412
- "application/json",
1413
- ],
1831
+ ...callbackSSOEndpointConfig,
1414
1832
  metadata: {
1415
- ...HIDE_METADATA,
1833
+ ...callbackSSOEndpointConfig.metadata,
1416
1834
  openapi: {
1417
- operationId: "handleSSOCallback",
1418
- summary: "Callback URL for SSO provider",
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 the callback URL for SSO providers. It handles the authorization code and exchanges it for an access token",
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
- if (!userInfo) {
1632
- if (!config.userInfoEndpoint) {
1633
- throw ctx.redirect(
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
- if (options?.provisionUser) {
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 ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
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 any).conditions, {
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 any).inResponseTo as string | undefined;
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 any).conditions as
2255
+ const conditions = (extract as SAMLAssertionExtract).conditions as
2134
2256
  | SAMLConditions
2135
2257
  | undefined;
2136
- const clockSkew = options?.saml?.clockSkew ?? DEFAULT_CLOCK_SKEW_MS;
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 ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
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 any).conditions, {
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 any).inResponseTo 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 any).conditions as
2772
+ const conditions = (extract as SAMLAssertionExtract).conditions as
2616
2773
  | SAMLConditions
2617
2774
  | undefined;
2618
- const clockSkew = options?.saml?.clockSkew ?? DEFAULT_CLOCK_SKEW_MS;
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
+ };