@better-auth/sso 1.4.7-beta.2 → 1.4.7-beta.3

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.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * AuthnRequest Store
3
+ *
4
+ * Tracks SAML AuthnRequest IDs to enable InResponseTo validation.
5
+ * This prevents:
6
+ * - Unsolicited SAML responses
7
+ * - Cross-provider response injection
8
+ * - Replay attacks
9
+ * - Expired login completions
10
+ */
11
+
12
+ export interface AuthnRequestRecord {
13
+ id: string;
14
+ providerId: string;
15
+ createdAt: number;
16
+ expiresAt: number;
17
+ }
18
+
19
+ export interface AuthnRequestStore {
20
+ save(record: AuthnRequestRecord): Promise<void>;
21
+ get(id: string): Promise<AuthnRequestRecord | null>;
22
+ delete(id: string): Promise<void>;
23
+ }
24
+
25
+ /**
26
+ * Default TTL for AuthnRequest records (5 minutes).
27
+ * This should be sufficient for most IdPs while protecting against stale requests.
28
+ */
29
+ export const DEFAULT_AUTHN_REQUEST_TTL_MS = 5 * 60 * 1000;
30
+
31
+ /**
32
+ * In-memory implementation of AuthnRequestStore.
33
+ * ⚠️ Only suitable for testing or single-instance non-serverless deployments.
34
+ * For production, rely on the default behavior (uses verification table)
35
+ * or provide a custom Redis-backed store.
36
+ */
37
+ export function createInMemoryAuthnRequestStore(): AuthnRequestStore {
38
+ const store = new Map<string, AuthnRequestRecord>();
39
+
40
+ const cleanup = () => {
41
+ const now = Date.now();
42
+ for (const [id, record] of store.entries()) {
43
+ if (record.expiresAt < now) {
44
+ store.delete(id);
45
+ }
46
+ }
47
+ };
48
+
49
+ const cleanupInterval = setInterval(cleanup, 60 * 1000);
50
+
51
+ if (typeof cleanupInterval.unref === "function") {
52
+ cleanupInterval.unref();
53
+ }
54
+
55
+ return {
56
+ async save(record: AuthnRequestRecord): Promise<void> {
57
+ store.set(record.id, record);
58
+ },
59
+
60
+ async get(id: string): Promise<AuthnRequestRecord | null> {
61
+ const record = store.get(id);
62
+ if (!record) {
63
+ return null;
64
+ }
65
+ if (record.expiresAt < Date.now()) {
66
+ store.delete(id);
67
+ return null;
68
+ }
69
+ return record;
70
+ },
71
+
72
+ async delete(id: string): Promise<void> {
73
+ store.delete(id);
74
+ },
75
+ };
76
+ }
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ createInMemoryAuthnRequestStore,
4
+ DEFAULT_AUTHN_REQUEST_TTL_MS,
5
+ } from "./authn-request-store";
6
+
7
+ describe("AuthnRequest Store", () => {
8
+ describe("In-Memory Store", () => {
9
+ it("should save and retrieve an AuthnRequest record", async () => {
10
+ const store = createInMemoryAuthnRequestStore();
11
+
12
+ const record = {
13
+ id: "_test-request-id-1",
14
+ providerId: "saml-provider-1",
15
+ createdAt: Date.now(),
16
+ expiresAt: Date.now() + DEFAULT_AUTHN_REQUEST_TTL_MS,
17
+ };
18
+
19
+ await store.save(record);
20
+ const retrieved = await store.get(record.id);
21
+
22
+ expect(retrieved).toEqual(record);
23
+ });
24
+
25
+ it("should return null for non-existent request ID", async () => {
26
+ const store = createInMemoryAuthnRequestStore();
27
+
28
+ const retrieved = await store.get("_non-existent-id");
29
+
30
+ expect(retrieved).toBeNull();
31
+ });
32
+
33
+ it("should return null for expired request ID", async () => {
34
+ const store = createInMemoryAuthnRequestStore();
35
+
36
+ const record = {
37
+ id: "_expired-request-id",
38
+ providerId: "saml-provider-1",
39
+ createdAt: Date.now() - 10000,
40
+ expiresAt: Date.now() - 1000, // Already expired
41
+ };
42
+
43
+ await store.save(record);
44
+ const retrieved = await store.get(record.id);
45
+
46
+ expect(retrieved).toBeNull();
47
+ });
48
+
49
+ it("should delete a request ID", async () => {
50
+ const store = createInMemoryAuthnRequestStore();
51
+
52
+ const record = {
53
+ id: "_delete-me",
54
+ providerId: "saml-provider-1",
55
+ createdAt: Date.now(),
56
+ expiresAt: Date.now() + DEFAULT_AUTHN_REQUEST_TTL_MS,
57
+ };
58
+
59
+ await store.save(record);
60
+ await store.delete(record.id);
61
+
62
+ const retrieved = await store.get(record.id);
63
+ expect(retrieved).toBeNull();
64
+ });
65
+
66
+ it("should handle multiple providers with different request IDs", async () => {
67
+ const store = createInMemoryAuthnRequestStore();
68
+
69
+ const record1 = {
70
+ id: "_request-provider-1",
71
+ providerId: "saml-provider-1",
72
+ createdAt: Date.now(),
73
+ expiresAt: Date.now() + DEFAULT_AUTHN_REQUEST_TTL_MS,
74
+ };
75
+
76
+ const record2 = {
77
+ id: "_request-provider-2",
78
+ providerId: "saml-provider-2",
79
+ createdAt: Date.now(),
80
+ expiresAt: Date.now() + DEFAULT_AUTHN_REQUEST_TTL_MS,
81
+ };
82
+
83
+ await store.save(record1);
84
+ await store.save(record2);
85
+
86
+ const retrieved1 = await store.get(record1.id);
87
+ const retrieved2 = await store.get(record2.id);
88
+
89
+ expect(retrieved1?.providerId).toBe("saml-provider-1");
90
+ expect(retrieved2?.providerId).toBe("saml-provider-2");
91
+ });
92
+ });
93
+
94
+ describe("DEFAULT_AUTHN_REQUEST_TTL_MS", () => {
95
+ it("should be 5 minutes in milliseconds", () => {
96
+ expect(DEFAULT_AUTHN_REQUEST_TTL_MS).toBe(5 * 60 * 1000);
97
+ });
98
+ });
99
+ });
package/src/index.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import type { BetterAuthPlugin } from "better-auth";
2
2
  import { XMLValidator } from "fast-xml-parser";
3
3
  import * as saml from "samlify";
4
+ import type {
5
+ AuthnRequestRecord,
6
+ AuthnRequestStore,
7
+ } from "./authn-request-store";
8
+ import {
9
+ createInMemoryAuthnRequestStore,
10
+ DEFAULT_AUTHN_REQUEST_TTL_MS,
11
+ } from "./authn-request-store";
4
12
  import {
5
13
  requestDomainVerification,
6
14
  verifyDomain,
@@ -16,6 +24,8 @@ import {
16
24
  import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "./types";
17
25
 
18
26
  export type { SAMLConfig, OIDCConfig, SSOOptions, SSOProvider };
27
+ export type { AuthnRequestStore, AuthnRequestRecord };
28
+ export { createInMemoryAuthnRequestStore, DEFAULT_AUTHN_REQUEST_TTL_MS };
19
29
 
20
30
  const fastValidator = {
21
31
  async validate(xml: string) {
@@ -71,19 +81,21 @@ export function sso<O extends SSOOptions>(
71
81
  };
72
82
 
73
83
  export function sso<O extends SSOOptions>(options?: O | undefined): any {
84
+ const optionsWithStore = options as O;
85
+
74
86
  let endpoints = {
75
87
  spMetadata: spMetadata(),
76
- registerSSOProvider: registerSSOProvider(options as O),
77
- signInSSO: signInSSO(options as O),
78
- callbackSSO: callbackSSO(options as O),
79
- callbackSSOSAML: callbackSSOSAML(options as O),
80
- acsEndpoint: acsEndpoint(options as O),
88
+ registerSSOProvider: registerSSOProvider(optionsWithStore),
89
+ signInSSO: signInSSO(optionsWithStore),
90
+ callbackSSO: callbackSSO(optionsWithStore),
91
+ callbackSSOSAML: callbackSSOSAML(optionsWithStore),
92
+ acsEndpoint: acsEndpoint(optionsWithStore),
81
93
  };
82
94
 
83
95
  if (options?.domainVerification?.enabled) {
84
96
  const domainVerificationEndpoints = {
85
- requestDomainVerification: requestDomainVerification(options as O),
86
- verifyDomain: verifyDomain(options as O),
97
+ requestDomainVerification: requestDomainVerification(optionsWithStore),
98
+ verifyDomain: verifyDomain(optionsWithStore),
87
99
  };
88
100
 
89
101
  endpoints = {
package/src/routes/sso.ts CHANGED
@@ -3,6 +3,7 @@ import type { Account, Session, User, Verification } from "better-auth";
3
3
  import {
4
4
  createAuthorizationURL,
5
5
  generateState,
6
+ HIDE_METADATA,
6
7
  parseState,
7
8
  validateAuthorizationCode,
8
9
  validateToken,
@@ -21,9 +22,13 @@ import type { BindingContext } from "samlify/types/src/entity";
21
22
  import type { IdentityProvider } from "samlify/types/src/entity-idp";
22
23
  import type { FlowResult } from "samlify/types/src/flow";
23
24
  import * as z from "zod/v4";
25
+ import type { AuthnRequestRecord } from "../authn-request-store";
26
+ import { DEFAULT_AUTHN_REQUEST_TTL_MS } from "../authn-request-store";
24
27
  import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "../types";
28
+
25
29
  import { safeJsonParse, validateEmailDomain } from "../utils";
26
30
 
31
+ const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
27
32
  const spMetadataQuerySchema = z.object({
28
33
  providerId: z.string(),
29
34
  format: z.enum(["xml", "json"]).default("xml"),
@@ -1054,12 +1059,40 @@ export const signInSSO = (options?: SSOOptions) => {
1054
1059
  const loginRequest = sp.createLoginRequest(
1055
1060
  idp,
1056
1061
  "redirect",
1057
- ) as BindingContext & { entityEndpoint: string; type: string };
1062
+ ) as BindingContext & {
1063
+ entityEndpoint: string;
1064
+ type: string;
1065
+ id: string;
1066
+ };
1058
1067
  if (!loginRequest) {
1059
1068
  throw new APIError("BAD_REQUEST", {
1060
1069
  message: "Invalid SAML request",
1061
1070
  });
1062
1071
  }
1072
+
1073
+ const shouldSaveRequest =
1074
+ loginRequest.id &&
1075
+ (options?.saml?.authnRequestStore ||
1076
+ options?.saml?.enableInResponseToValidation);
1077
+ if (shouldSaveRequest) {
1078
+ const ttl = options?.saml?.requestTTL ?? DEFAULT_AUTHN_REQUEST_TTL_MS;
1079
+ const record: AuthnRequestRecord = {
1080
+ id: loginRequest.id,
1081
+ providerId: provider.providerId,
1082
+ createdAt: Date.now(),
1083
+ expiresAt: Date.now() + ttl,
1084
+ };
1085
+ if (options?.saml?.authnRequestStore) {
1086
+ await options.saml.authnRequestStore.save(record);
1087
+ } else {
1088
+ await ctx.context.internalAdapter.createVerificationValue({
1089
+ identifier: `${AUTHN_REQUEST_KEY_PREFIX}${record.id}`,
1090
+ value: JSON.stringify(record),
1091
+ expiresAt: new Date(record.expiresAt),
1092
+ });
1093
+ }
1094
+ }
1095
+
1063
1096
  return ctx.json({
1064
1097
  url: `${loginRequest.context}&RelayState=${encodeURIComponent(
1065
1098
  body.callbackURL,
@@ -1092,7 +1125,7 @@ export const callbackSSO = (options?: SSOOptions) => {
1092
1125
  "application/json",
1093
1126
  ],
1094
1127
  metadata: {
1095
- isAction: false,
1128
+ ...HIDE_METADATA,
1096
1129
  openapi: {
1097
1130
  operationId: "handleSSOCallback",
1098
1131
  summary: "Callback URL for SSO provider",
@@ -1461,7 +1494,7 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1461
1494
  method: "POST",
1462
1495
  body: callbackSSOSAMLBodySchema,
1463
1496
  metadata: {
1464
- isAction: false,
1497
+ ...HIDE_METADATA,
1465
1498
  allowedMediaTypes: [
1466
1499
  "application/x-www-form-urlencoded",
1467
1500
  "application/json",
@@ -1603,31 +1636,12 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1603
1636
 
1604
1637
  let parsedResponse: FlowResult;
1605
1638
  try {
1606
- const decodedResponse = Buffer.from(SAMLResponse, "base64").toString(
1607
- "utf-8",
1608
- );
1609
-
1610
- try {
1611
- parsedResponse = await sp.parseLoginResponse(idp, "post", {
1612
- body: {
1613
- SAMLResponse,
1614
- RelayState: RelayState || undefined,
1615
- },
1616
- });
1617
- } catch (parseError) {
1618
- const nameIDMatch = decodedResponse.match(
1619
- /<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
1620
- );
1621
- if (!nameIDMatch) throw parseError;
1622
- parsedResponse = {
1623
- extract: {
1624
- nameID: nameIDMatch[1],
1625
- attributes: { nameID: nameIDMatch[1] },
1626
- sessionIndex: {},
1627
- conditions: {},
1628
- },
1629
- } as FlowResult;
1630
- }
1639
+ parsedResponse = await sp.parseLoginResponse(idp, "post", {
1640
+ body: {
1641
+ SAMLResponse,
1642
+ RelayState: RelayState || undefined,
1643
+ },
1644
+ });
1631
1645
 
1632
1646
  if (!parsedResponse?.extract) {
1633
1647
  throw new Error("Invalid SAML response structure");
@@ -1646,6 +1660,93 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1646
1660
  }
1647
1661
 
1648
1662
  const { extract } = parsedResponse!;
1663
+
1664
+ const inResponseTo = (extract as any).inResponseTo as string | undefined;
1665
+ const shouldValidateInResponseTo =
1666
+ options?.saml?.authnRequestStore ||
1667
+ options?.saml?.enableInResponseToValidation;
1668
+
1669
+ if (shouldValidateInResponseTo) {
1670
+ const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
1671
+
1672
+ if (inResponseTo) {
1673
+ let storedRequest: AuthnRequestRecord | null = null;
1674
+
1675
+ if (options?.saml?.authnRequestStore) {
1676
+ storedRequest =
1677
+ await options.saml.authnRequestStore.get(inResponseTo);
1678
+ } else {
1679
+ const verification =
1680
+ await ctx.context.internalAdapter.findVerificationValue(
1681
+ `${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
1682
+ );
1683
+ if (verification) {
1684
+ try {
1685
+ storedRequest = JSON.parse(
1686
+ verification.value,
1687
+ ) as AuthnRequestRecord;
1688
+ } catch {
1689
+ storedRequest = null;
1690
+ }
1691
+ }
1692
+ }
1693
+
1694
+ if (!storedRequest) {
1695
+ ctx.context.logger.error(
1696
+ "SAML InResponseTo validation failed: unknown or expired request ID",
1697
+ { inResponseTo, providerId: provider.providerId },
1698
+ );
1699
+ const redirectUrl =
1700
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1701
+ throw ctx.redirect(
1702
+ `${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`,
1703
+ );
1704
+ }
1705
+
1706
+ if (storedRequest.providerId !== provider.providerId) {
1707
+ ctx.context.logger.error(
1708
+ "SAML InResponseTo validation failed: provider mismatch",
1709
+ {
1710
+ inResponseTo,
1711
+ expectedProvider: storedRequest.providerId,
1712
+ actualProvider: provider.providerId,
1713
+ },
1714
+ );
1715
+
1716
+ if (options?.saml?.authnRequestStore) {
1717
+ await options.saml.authnRequestStore.delete(inResponseTo);
1718
+ } else {
1719
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(
1720
+ `${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
1721
+ );
1722
+ }
1723
+ const redirectUrl =
1724
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1725
+ throw ctx.redirect(
1726
+ `${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`,
1727
+ );
1728
+ }
1729
+
1730
+ if (options?.saml?.authnRequestStore) {
1731
+ await options.saml.authnRequestStore.delete(inResponseTo);
1732
+ } else {
1733
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(
1734
+ `${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
1735
+ );
1736
+ }
1737
+ } else if (!allowIdpInitiated) {
1738
+ ctx.context.logger.error(
1739
+ "SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false",
1740
+ { providerId: provider.providerId },
1741
+ );
1742
+ const redirectUrl =
1743
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1744
+ throw ctx.redirect(
1745
+ `${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`,
1746
+ );
1747
+ }
1748
+ }
1749
+
1649
1750
  const attributes = extract.attributes || {};
1650
1751
  const mapping = parsedSamlConfig.mapping ?? {};
1651
1752
 
@@ -1831,7 +1932,7 @@ export const acsEndpoint = (options?: SSOOptions) => {
1831
1932
  params: acsEndpointParamsSchema,
1832
1933
  body: acsEndpointBodySchema,
1833
1934
  metadata: {
1834
- isAction: false,
1935
+ ...HIDE_METADATA,
1835
1936
  allowedMediaTypes: [
1836
1937
  "application/x-www-form-urlencoded",
1837
1938
  "application/json",
@@ -1960,50 +2061,12 @@ export const acsEndpoint = (options?: SSOOptions) => {
1960
2061
  // Parse and validate SAML response
1961
2062
  let parsedResponse: FlowResult;
1962
2063
  try {
1963
- let decodedResponse = Buffer.from(SAMLResponse, "base64").toString(
1964
- "utf-8",
1965
- );
1966
-
1967
- // Patch the SAML response if status is missing or not success
1968
- if (!decodedResponse.includes("StatusCode")) {
1969
- // Insert a success status if missing
1970
- const insertPoint = decodedResponse.indexOf("</saml2:Issuer>");
1971
- if (insertPoint !== -1) {
1972
- decodedResponse =
1973
- decodedResponse.slice(0, insertPoint + 14) +
1974
- '<saml2:Status><saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></saml2:Status>' +
1975
- decodedResponse.slice(insertPoint + 14);
1976
- }
1977
- } else if (!decodedResponse.includes("saml2:Success")) {
1978
- // Replace existing non-success status with success
1979
- decodedResponse = decodedResponse.replace(
1980
- /<saml2:StatusCode Value="[^"]+"/,
1981
- '<saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"',
1982
- );
1983
- }
1984
-
1985
- try {
1986
- parsedResponse = await sp.parseLoginResponse(idp, "post", {
1987
- body: {
1988
- SAMLResponse,
1989
- RelayState: RelayState || undefined,
1990
- },
1991
- });
1992
- } catch (parseError) {
1993
- const nameIDMatch = decodedResponse.match(
1994
- /<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
1995
- );
1996
- // due to different spec. we have to make sure to handle that.
1997
- if (!nameIDMatch) throw parseError;
1998
- parsedResponse = {
1999
- extract: {
2000
- nameID: nameIDMatch[1],
2001
- attributes: { nameID: nameIDMatch[1] },
2002
- sessionIndex: {},
2003
- conditions: {},
2004
- },
2005
- } as FlowResult;
2006
- }
2064
+ parsedResponse = await sp.parseLoginResponse(idp, "post", {
2065
+ body: {
2066
+ SAMLResponse,
2067
+ RelayState: RelayState || undefined,
2068
+ },
2069
+ });
2007
2070
 
2008
2071
  if (!parsedResponse?.extract) {
2009
2072
  throw new Error("Invalid SAML response structure");
@@ -2022,6 +2085,94 @@ export const acsEndpoint = (options?: SSOOptions) => {
2022
2085
  }
2023
2086
 
2024
2087
  const { extract } = parsedResponse!;
2088
+
2089
+ const inResponseToAcs = (extract as any).inResponseTo as
2090
+ | string
2091
+ | undefined;
2092
+ const shouldValidateInResponseToAcs =
2093
+ options?.saml?.authnRequestStore ||
2094
+ options?.saml?.enableInResponseToValidation;
2095
+
2096
+ if (shouldValidateInResponseToAcs) {
2097
+ const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
2098
+
2099
+ if (inResponseToAcs) {
2100
+ let storedRequest: AuthnRequestRecord | null = null;
2101
+
2102
+ if (options?.saml?.authnRequestStore) {
2103
+ storedRequest =
2104
+ await options.saml.authnRequestStore.get(inResponseToAcs);
2105
+ } else {
2106
+ const verification =
2107
+ await ctx.context.internalAdapter.findVerificationValue(
2108
+ `${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
2109
+ );
2110
+ if (verification) {
2111
+ try {
2112
+ storedRequest = JSON.parse(
2113
+ verification.value,
2114
+ ) as AuthnRequestRecord;
2115
+ } catch {
2116
+ storedRequest = null;
2117
+ }
2118
+ }
2119
+ }
2120
+
2121
+ if (!storedRequest) {
2122
+ ctx.context.logger.error(
2123
+ "SAML InResponseTo validation failed: unknown or expired request ID",
2124
+ { inResponseTo: inResponseToAcs, providerId },
2125
+ );
2126
+ const redirectUrl =
2127
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2128
+ throw ctx.redirect(
2129
+ `${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`,
2130
+ );
2131
+ }
2132
+
2133
+ if (storedRequest.providerId !== providerId) {
2134
+ ctx.context.logger.error(
2135
+ "SAML InResponseTo validation failed: provider mismatch",
2136
+ {
2137
+ inResponseTo: inResponseToAcs,
2138
+ expectedProvider: storedRequest.providerId,
2139
+ actualProvider: providerId,
2140
+ },
2141
+ );
2142
+ if (options?.saml?.authnRequestStore) {
2143
+ await options.saml.authnRequestStore.delete(inResponseToAcs);
2144
+ } else {
2145
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(
2146
+ `${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
2147
+ );
2148
+ }
2149
+ const redirectUrl =
2150
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2151
+ throw ctx.redirect(
2152
+ `${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`,
2153
+ );
2154
+ }
2155
+
2156
+ if (options?.saml?.authnRequestStore) {
2157
+ await options.saml.authnRequestStore.delete(inResponseToAcs);
2158
+ } else {
2159
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(
2160
+ `${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
2161
+ );
2162
+ }
2163
+ } else if (!allowIdpInitiated) {
2164
+ ctx.context.logger.error(
2165
+ "SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false",
2166
+ { providerId },
2167
+ );
2168
+ const redirectUrl =
2169
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2170
+ throw ctx.redirect(
2171
+ `${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`,
2172
+ );
2173
+ }
2174
+ }
2175
+
2025
2176
  const attributes = extract.attributes || {};
2026
2177
  const mapping = parsedSamlConfig.mapping ?? {};
2027
2178