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

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
@@ -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,100 @@ 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";
27
+ import type { HydratedOIDCConfig } from "../oidc";
28
+ import {
29
+ DiscoveryError,
30
+ discoverOIDCConfig,
31
+ mapDiscoveryErrorToAPIError,
32
+ } from "../oidc";
24
33
  import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "../types";
34
+
25
35
  import { safeJsonParse, validateEmailDomain } from "../utils";
26
36
 
37
+ const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
38
+
39
+ /** Default clock skew tolerance: 5 minutes */
40
+ export const DEFAULT_CLOCK_SKEW_MS = 5 * 60 * 1000;
41
+
42
+ export interface TimestampValidationOptions {
43
+ clockSkew?: number;
44
+ requireTimestamps?: boolean;
45
+ logger?: {
46
+ warn: (message: string, data?: Record<string, unknown>) => void;
47
+ };
48
+ }
49
+
50
+ /** Conditions extracted from SAML assertion */
51
+ export interface SAMLConditions {
52
+ notBefore?: string;
53
+ notOnOrAfter?: string;
54
+ }
55
+
56
+ /**
57
+ * Validates SAML assertion timestamp conditions (NotBefore/NotOnOrAfter).
58
+ * Prevents acceptance of expired or future-dated assertions.
59
+ * @throws {APIError} If timestamps are invalid, expired, or not yet valid
60
+ */
61
+ export function validateSAMLTimestamp(
62
+ conditions: SAMLConditions | undefined,
63
+ options: TimestampValidationOptions = {},
64
+ ): void {
65
+ const clockSkew = options.clockSkew ?? DEFAULT_CLOCK_SKEW_MS;
66
+ const hasTimestamps = conditions?.notBefore || conditions?.notOnOrAfter;
67
+
68
+ if (!hasTimestamps) {
69
+ if (options.requireTimestamps) {
70
+ throw new APIError("BAD_REQUEST", {
71
+ message: "SAML assertion missing required timestamp conditions",
72
+ details:
73
+ "Assertions must include NotBefore and/or NotOnOrAfter conditions",
74
+ });
75
+ }
76
+ // Log warning for missing timestamps when not required
77
+ options.logger?.warn(
78
+ "SAML assertion accepted without timestamp conditions",
79
+ { hasConditions: !!conditions },
80
+ );
81
+ return;
82
+ }
83
+
84
+ const now = Date.now();
85
+
86
+ if (conditions?.notBefore) {
87
+ const notBeforeTime = new Date(conditions.notBefore).getTime();
88
+ if (Number.isNaN(notBeforeTime)) {
89
+ throw new APIError("BAD_REQUEST", {
90
+ message: "SAML assertion has invalid NotBefore timestamp",
91
+ details: `Unable to parse NotBefore value: ${conditions.notBefore}`,
92
+ });
93
+ }
94
+ if (now < notBeforeTime - clockSkew) {
95
+ throw new APIError("BAD_REQUEST", {
96
+ message: "SAML assertion is not yet valid",
97
+ details: `Current time is before NotBefore (with ${clockSkew}ms clock skew tolerance)`,
98
+ });
99
+ }
100
+ }
101
+
102
+ if (conditions?.notOnOrAfter) {
103
+ const notOnOrAfterTime = new Date(conditions.notOnOrAfter).getTime();
104
+ if (Number.isNaN(notOnOrAfterTime)) {
105
+ throw new APIError("BAD_REQUEST", {
106
+ message: "SAML assertion has invalid NotOnOrAfter timestamp",
107
+ details: `Unable to parse NotOnOrAfter value: ${conditions.notOnOrAfter}`,
108
+ });
109
+ }
110
+ if (now > notOnOrAfterTime + clockSkew) {
111
+ throw new APIError("BAD_REQUEST", {
112
+ message: "SAML assertion has expired",
113
+ details: `Current time is after NotOnOrAfter (with ${clockSkew}ms clock skew tolerance)`,
114
+ });
115
+ }
116
+ }
117
+ }
118
+
27
119
  const spMetadataQuerySchema = z.object({
28
120
  providerId: z.string(),
29
121
  format: z.enum(["xml", "json"]).default("xml"),
@@ -149,6 +241,13 @@ const ssoProviderBodySchema = z.object({
149
241
  })
150
242
  .optional(),
151
243
  discoveryEndpoint: z.string().optional(),
244
+ skipDiscovery: z
245
+ .boolean()
246
+ .meta({
247
+ description:
248
+ "Skip OIDC discovery during registration. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually.",
249
+ })
250
+ .optional(),
152
251
  scopes: z
153
252
  .array(z.string(), {})
154
253
  .meta({
@@ -568,6 +667,80 @@ export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
568
667
  });
569
668
  }
570
669
 
670
+ let hydratedOIDCConfig: HydratedOIDCConfig | null = null;
671
+ if (body.oidcConfig && !body.oidcConfig.skipDiscovery) {
672
+ try {
673
+ hydratedOIDCConfig = await discoverOIDCConfig({
674
+ issuer: body.issuer,
675
+ existingConfig: {
676
+ discoveryEndpoint: body.oidcConfig.discoveryEndpoint,
677
+ authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
678
+ tokenEndpoint: body.oidcConfig.tokenEndpoint,
679
+ jwksEndpoint: body.oidcConfig.jwksEndpoint,
680
+ userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
681
+ tokenEndpointAuthentication:
682
+ body.oidcConfig.tokenEndpointAuthentication,
683
+ },
684
+ });
685
+ } catch (error) {
686
+ if (error instanceof DiscoveryError) {
687
+ throw mapDiscoveryErrorToAPIError(error);
688
+ }
689
+ throw error;
690
+ }
691
+ }
692
+
693
+ const buildOIDCConfig = () => {
694
+ if (!body.oidcConfig) return null;
695
+
696
+ if (body.oidcConfig.skipDiscovery) {
697
+ return JSON.stringify({
698
+ issuer: body.issuer,
699
+ clientId: body.oidcConfig.clientId,
700
+ clientSecret: body.oidcConfig.clientSecret,
701
+ authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
702
+ tokenEndpoint: body.oidcConfig.tokenEndpoint,
703
+ tokenEndpointAuthentication:
704
+ body.oidcConfig.tokenEndpointAuthentication ||
705
+ "client_secret_basic",
706
+ jwksEndpoint: body.oidcConfig.jwksEndpoint,
707
+ pkce: body.oidcConfig.pkce,
708
+ discoveryEndpoint:
709
+ body.oidcConfig.discoveryEndpoint ||
710
+ `${body.issuer}/.well-known/openid-configuration`,
711
+ mapping: body.oidcConfig.mapping,
712
+ scopes: body.oidcConfig.scopes,
713
+ userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
714
+ overrideUserInfo:
715
+ ctx.body.overrideUserInfo ||
716
+ options?.defaultOverrideUserInfo ||
717
+ false,
718
+ });
719
+ }
720
+
721
+ if (!hydratedOIDCConfig) return null;
722
+
723
+ return JSON.stringify({
724
+ issuer: hydratedOIDCConfig.issuer,
725
+ clientId: body.oidcConfig.clientId,
726
+ clientSecret: body.oidcConfig.clientSecret,
727
+ authorizationEndpoint: hydratedOIDCConfig.authorizationEndpoint,
728
+ tokenEndpoint: hydratedOIDCConfig.tokenEndpoint,
729
+ tokenEndpointAuthentication:
730
+ hydratedOIDCConfig.tokenEndpointAuthentication,
731
+ jwksEndpoint: hydratedOIDCConfig.jwksEndpoint,
732
+ pkce: body.oidcConfig.pkce,
733
+ discoveryEndpoint: hydratedOIDCConfig.discoveryEndpoint,
734
+ mapping: body.oidcConfig.mapping,
735
+ scopes: body.oidcConfig.scopes,
736
+ userInfoEndpoint: hydratedOIDCConfig.userInfoEndpoint,
737
+ overrideUserInfo:
738
+ ctx.body.overrideUserInfo ||
739
+ options?.defaultOverrideUserInfo ||
740
+ false,
741
+ });
742
+ };
743
+
571
744
  const provider = await ctx.context.adapter.create<
572
745
  Record<string, any>,
573
746
  SSOProvider<O>
@@ -577,29 +750,7 @@ export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
577
750
  issuer: body.issuer,
578
751
  domain: body.domain,
579
752
  domainVerified: false,
580
- oidcConfig: body.oidcConfig
581
- ? JSON.stringify({
582
- issuer: body.issuer,
583
- clientId: body.oidcConfig.clientId,
584
- clientSecret: body.oidcConfig.clientSecret,
585
- authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
586
- tokenEndpoint: body.oidcConfig.tokenEndpoint,
587
- tokenEndpointAuthentication:
588
- body.oidcConfig.tokenEndpointAuthentication,
589
- jwksEndpoint: body.oidcConfig.jwksEndpoint,
590
- pkce: body.oidcConfig.pkce,
591
- discoveryEndpoint:
592
- body.oidcConfig.discoveryEndpoint ||
593
- `${body.issuer}/.well-known/openid-configuration`,
594
- mapping: body.oidcConfig.mapping,
595
- scopes: body.oidcConfig.scopes,
596
- userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
597
- overrideUserInfo:
598
- ctx.body.overrideUserInfo ||
599
- options?.defaultOverrideUserInfo ||
600
- false,
601
- })
602
- : null,
753
+ oidcConfig: buildOIDCConfig(),
603
754
  samlConfig: body.samlConfig
604
755
  ? JSON.stringify({
605
756
  issuer: body.issuer,
@@ -1054,12 +1205,40 @@ export const signInSSO = (options?: SSOOptions) => {
1054
1205
  const loginRequest = sp.createLoginRequest(
1055
1206
  idp,
1056
1207
  "redirect",
1057
- ) as BindingContext & { entityEndpoint: string; type: string };
1208
+ ) as BindingContext & {
1209
+ entityEndpoint: string;
1210
+ type: string;
1211
+ id: string;
1212
+ };
1058
1213
  if (!loginRequest) {
1059
1214
  throw new APIError("BAD_REQUEST", {
1060
1215
  message: "Invalid SAML request",
1061
1216
  });
1062
1217
  }
1218
+
1219
+ const shouldSaveRequest =
1220
+ loginRequest.id &&
1221
+ (options?.saml?.authnRequestStore ||
1222
+ options?.saml?.enableInResponseToValidation);
1223
+ if (shouldSaveRequest) {
1224
+ const ttl = options?.saml?.requestTTL ?? DEFAULT_AUTHN_REQUEST_TTL_MS;
1225
+ const record: AuthnRequestRecord = {
1226
+ id: loginRequest.id,
1227
+ providerId: provider.providerId,
1228
+ createdAt: Date.now(),
1229
+ expiresAt: Date.now() + ttl,
1230
+ };
1231
+ if (options?.saml?.authnRequestStore) {
1232
+ await options.saml.authnRequestStore.save(record);
1233
+ } else {
1234
+ await ctx.context.internalAdapter.createVerificationValue({
1235
+ identifier: `${AUTHN_REQUEST_KEY_PREFIX}${record.id}`,
1236
+ value: JSON.stringify(record),
1237
+ expiresAt: new Date(record.expiresAt),
1238
+ });
1239
+ }
1240
+ }
1241
+
1063
1242
  return ctx.json({
1064
1243
  url: `${loginRequest.context}&RelayState=${encodeURIComponent(
1065
1244
  body.callbackURL,
@@ -1092,7 +1271,7 @@ export const callbackSSO = (options?: SSOOptions) => {
1092
1271
  "application/json",
1093
1272
  ],
1094
1273
  metadata: {
1095
- isAction: false,
1274
+ ...HIDE_METADATA,
1096
1275
  openapi: {
1097
1276
  operationId: "handleSSOCallback",
1098
1277
  summary: "Callback URL for SSO provider",
@@ -1461,7 +1640,7 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1461
1640
  method: "POST",
1462
1641
  body: callbackSSOSAMLBodySchema,
1463
1642
  metadata: {
1464
- isAction: false,
1643
+ ...HIDE_METADATA,
1465
1644
  allowedMediaTypes: [
1466
1645
  "application/x-www-form-urlencoded",
1467
1646
  "application/json",
@@ -1603,31 +1782,12 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1603
1782
 
1604
1783
  let parsedResponse: FlowResult;
1605
1784
  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
- }
1785
+ parsedResponse = await sp.parseLoginResponse(idp, "post", {
1786
+ body: {
1787
+ SAMLResponse,
1788
+ RelayState: RelayState || undefined,
1789
+ },
1790
+ });
1631
1791
 
1632
1792
  if (!parsedResponse?.extract) {
1633
1793
  throw new Error("Invalid SAML response structure");
@@ -1646,6 +1806,106 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1646
1806
  }
1647
1807
 
1648
1808
  const { extract } = parsedResponse!;
1809
+
1810
+ validateSAMLTimestamp((extract as any).conditions, {
1811
+ clockSkew: options?.saml?.clockSkew,
1812
+ requireTimestamps: options?.saml?.requireTimestamps,
1813
+ logger: ctx.context.logger,
1814
+ });
1815
+
1816
+ const inResponseTo = (extract as any).inResponseTo as string | undefined;
1817
+ const shouldValidateInResponseTo =
1818
+ options?.saml?.authnRequestStore ||
1819
+ options?.saml?.enableInResponseToValidation;
1820
+
1821
+ if (shouldValidateInResponseTo) {
1822
+ const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
1823
+
1824
+ if (inResponseTo) {
1825
+ let storedRequest: AuthnRequestRecord | null = null;
1826
+
1827
+ if (options?.saml?.authnRequestStore) {
1828
+ storedRequest =
1829
+ await options.saml.authnRequestStore.get(inResponseTo);
1830
+ } else {
1831
+ const verification =
1832
+ await ctx.context.internalAdapter.findVerificationValue(
1833
+ `${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
1834
+ );
1835
+ if (verification) {
1836
+ try {
1837
+ storedRequest = JSON.parse(
1838
+ verification.value,
1839
+ ) as AuthnRequestRecord;
1840
+ // Validate expiration for database-stored records
1841
+ // Note: Cleanup of expired records is handled automatically by
1842
+ // findVerificationValue, but we still need to check expiration
1843
+ // since the record is returned before cleanup runs
1844
+ if (storedRequest && storedRequest.expiresAt < Date.now()) {
1845
+ storedRequest = null;
1846
+ }
1847
+ } catch {
1848
+ storedRequest = null;
1849
+ }
1850
+ }
1851
+ }
1852
+
1853
+ if (!storedRequest) {
1854
+ ctx.context.logger.error(
1855
+ "SAML InResponseTo validation failed: unknown or expired request ID",
1856
+ { inResponseTo, providerId: provider.providerId },
1857
+ );
1858
+ const redirectUrl =
1859
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1860
+ throw ctx.redirect(
1861
+ `${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`,
1862
+ );
1863
+ }
1864
+
1865
+ if (storedRequest.providerId !== provider.providerId) {
1866
+ ctx.context.logger.error(
1867
+ "SAML InResponseTo validation failed: provider mismatch",
1868
+ {
1869
+ inResponseTo,
1870
+ expectedProvider: storedRequest.providerId,
1871
+ actualProvider: provider.providerId,
1872
+ },
1873
+ );
1874
+
1875
+ if (options?.saml?.authnRequestStore) {
1876
+ await options.saml.authnRequestStore.delete(inResponseTo);
1877
+ } else {
1878
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(
1879
+ `${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
1880
+ );
1881
+ }
1882
+ const redirectUrl =
1883
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1884
+ throw ctx.redirect(
1885
+ `${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`,
1886
+ );
1887
+ }
1888
+
1889
+ if (options?.saml?.authnRequestStore) {
1890
+ await options.saml.authnRequestStore.delete(inResponseTo);
1891
+ } else {
1892
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(
1893
+ `${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
1894
+ );
1895
+ }
1896
+ } else if (!allowIdpInitiated) {
1897
+ ctx.context.logger.error(
1898
+ "SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false",
1899
+ { providerId: provider.providerId },
1900
+ );
1901
+ const redirectUrl =
1902
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1903
+ throw ctx.redirect(
1904
+ `${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`,
1905
+ );
1906
+ }
1907
+ }
1908
+
1649
1909
  const attributes = extract.attributes || {};
1650
1910
  const mapping = parsedSamlConfig.mapping ?? {};
1651
1911
 
@@ -1831,7 +2091,7 @@ export const acsEndpoint = (options?: SSOOptions) => {
1831
2091
  params: acsEndpointParamsSchema,
1832
2092
  body: acsEndpointBodySchema,
1833
2093
  metadata: {
1834
- isAction: false,
2094
+ ...HIDE_METADATA,
1835
2095
  allowedMediaTypes: [
1836
2096
  "application/x-www-form-urlencoded",
1837
2097
  "application/json",
@@ -1960,50 +2220,12 @@ export const acsEndpoint = (options?: SSOOptions) => {
1960
2220
  // Parse and validate SAML response
1961
2221
  let parsedResponse: FlowResult;
1962
2222
  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
- }
2223
+ parsedResponse = await sp.parseLoginResponse(idp, "post", {
2224
+ body: {
2225
+ SAMLResponse,
2226
+ RelayState: RelayState || undefined,
2227
+ },
2228
+ });
2007
2229
 
2008
2230
  if (!parsedResponse?.extract) {
2009
2231
  throw new Error("Invalid SAML response structure");
@@ -2022,6 +2244,103 @@ export const acsEndpoint = (options?: SSOOptions) => {
2022
2244
  }
2023
2245
 
2024
2246
  const { extract } = parsedResponse!;
2247
+
2248
+ validateSAMLTimestamp((extract as any).conditions, {
2249
+ clockSkew: options?.saml?.clockSkew,
2250
+ requireTimestamps: options?.saml?.requireTimestamps,
2251
+ logger: ctx.context.logger,
2252
+ });
2253
+
2254
+ const inResponseToAcs = (extract as any).inResponseTo as
2255
+ | string
2256
+ | undefined;
2257
+ const shouldValidateInResponseToAcs =
2258
+ options?.saml?.authnRequestStore ||
2259
+ options?.saml?.enableInResponseToValidation;
2260
+
2261
+ if (shouldValidateInResponseToAcs) {
2262
+ const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
2263
+
2264
+ if (inResponseToAcs) {
2265
+ let storedRequest: AuthnRequestRecord | null = null;
2266
+
2267
+ if (options?.saml?.authnRequestStore) {
2268
+ storedRequest =
2269
+ await options.saml.authnRequestStore.get(inResponseToAcs);
2270
+ } else {
2271
+ const verification =
2272
+ await ctx.context.internalAdapter.findVerificationValue(
2273
+ `${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
2274
+ );
2275
+ if (verification) {
2276
+ try {
2277
+ storedRequest = JSON.parse(
2278
+ verification.value,
2279
+ ) as AuthnRequestRecord;
2280
+ if (storedRequest && storedRequest.expiresAt < Date.now()) {
2281
+ storedRequest = null;
2282
+ }
2283
+ } catch {
2284
+ storedRequest = null;
2285
+ }
2286
+ }
2287
+ }
2288
+
2289
+ if (!storedRequest) {
2290
+ ctx.context.logger.error(
2291
+ "SAML InResponseTo validation failed: unknown or expired request ID",
2292
+ { inResponseTo: inResponseToAcs, providerId },
2293
+ );
2294
+ const redirectUrl =
2295
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2296
+ throw ctx.redirect(
2297
+ `${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`,
2298
+ );
2299
+ }
2300
+
2301
+ if (storedRequest.providerId !== providerId) {
2302
+ ctx.context.logger.error(
2303
+ "SAML InResponseTo validation failed: provider mismatch",
2304
+ {
2305
+ inResponseTo: inResponseToAcs,
2306
+ expectedProvider: storedRequest.providerId,
2307
+ actualProvider: providerId,
2308
+ },
2309
+ );
2310
+ if (options?.saml?.authnRequestStore) {
2311
+ await options.saml.authnRequestStore.delete(inResponseToAcs);
2312
+ } else {
2313
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(
2314
+ `${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
2315
+ );
2316
+ }
2317
+ const redirectUrl =
2318
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2319
+ throw ctx.redirect(
2320
+ `${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`,
2321
+ );
2322
+ }
2323
+
2324
+ if (options?.saml?.authnRequestStore) {
2325
+ await options.saml.authnRequestStore.delete(inResponseToAcs);
2326
+ } else {
2327
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(
2328
+ `${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
2329
+ );
2330
+ }
2331
+ } else if (!allowIdpInitiated) {
2332
+ ctx.context.logger.error(
2333
+ "SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false",
2334
+ { providerId },
2335
+ );
2336
+ const redirectUrl =
2337
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2338
+ throw ctx.redirect(
2339
+ `${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`,
2340
+ );
2341
+ }
2342
+ }
2343
+
2025
2344
  const attributes = extract.attributes || {};
2026
2345
  const mapping = parsedSamlConfig.mapping ?? {};
2027
2346