{"record":{"id":"937a9d743af7d3d9","repo":"decolua/9router","slug":"inresponseto-mismatch-expected-expectedrequesti","errorCode":null,"errorMessage":"InResponseTo mismatch: expected ${expectedRequestId}, received ${inResponseTo || \"none\"}","messagePattern":"InResponseTo mismatch: expected (.+?), received (.+?)","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"src/lib/auth/saml.js","lineNumber":155,"sourceCode":"\n  const origin = getSamlBaseUrl(request, settings);\n  const samlInstance = createSamlInstance(settings, origin);\n\n  const container = typeof body === \"object\" && body !== null ? body : { SAMLResponse: body };\n  const rawSamlResponse = container.SAMLResponse;\n\n  if (!rawSamlResponse) {\n    throw new Error(\"Missing SAMLResponse parameter in assertion POST body\");\n  }\n\n  // Parse response XML to inspect InResponseTo for replay protection\n  if (expectedRequestId) {\n    const xml = Buffer.from(rawSamlResponse, \"base64\").toString(\"utf8\");\n    const match = xml.match(/InResponseTo=[\"']([^\"']+)[\"']/i);\n    const inResponseTo = match ? match[1] : null;\n\n    if (!inResponseTo || inResponseTo !== expectedRequestId) {\n      throw new Error(`InResponseTo mismatch: expected ${expectedRequestId}, received ${inResponseTo || \"none\"}`);\n    }\n  }\n\n  const result = await samlInstance.validatePostResponseAsync({ SAMLResponse: rawSamlResponse });\n  const profile = result?.profile || result;\n\n  return profile;\n}\n\n/**\n * Generates standard SP XML Metadata.\n * @param {string} origin\n * @param {object} settings\n * @returns {string}\n */\nexport function generateSamlMetadata(origin, settings) {\n  const samlInstance = createSamlInstance(settings, origin);\n  return samlInstance.generateServiceProviderMetadata();","sourceCodeStart":137,"sourceCodeEnd":173,"githubUrl":"https://github.com/decolua/9router/blob/90b52e06ffd666b7929554211474d01588f6b1f8/src/lib/auth/saml.js#L137-L173","documentation":"When an expectedRequestId is supplied (from the saml_state cookie), the function decodes the SAMLResponse XML and checks its InResponseTo attribute matches. SAML IdPs echo this request ID in responses to SP-initiated login; a mismatch means the response does not correspond to the outstanding auth request. This is a replay/CSRF protection: stale, forged, or unsolicited responses are rejected before full signature validation.","triggerScenarios":"Replaying a previously captured SAMLResponse whose InResponseTo is an old request ID; posting an IdP-initiated (unsolicited) response that has no InResponseTo (null match); a saml_state cookie from a different/abandoned login attempt; the regex finds no InResponseTo because the attribute is absent or uses different casing/namespace.","commonSituations":"User opened two login tabs, completed one, and the other submitted with the first cookie; back-button resubmission of an old assertion; clock/flow issues causing IdP-initiated responses to hit an SP-initiated callback; multiple SAML requests queued so request IDs no longer line up.","solutions":["Restart the login flow: clear saml_state, hit /login again to mint a fresh request ID, and complete the IdP redirect in the same session/tab.","Verify the saml_state cookie survives the IdP round trip (correct domain, SameSite=None+Secure for cross-site POST, not stripped by a proxy).","For IdP-initiated SSO, do not pass expectedRequestId (or branch on flow type) since such responses legitimately omit InResponseTo.","Avoid replaying cached SAMLResponse values in scripts/tests — each assertion is bound to one request ID; generate a new auth request per attempt."],"exampleFix":"// before\nconst result = await validateSamlResponse(req, body, staleStateId, settings); // stale cookie\n// after\nconst stateId = req.cookies.saml_state; // read the cookie bound to THIS flow\nif (!stateId) return redirectToLogin(); // start a fresh SP-initiated request\nconst result = await validateSamlResponse(req, body, stateId, settings);","handlingStrategy":"try-catch","validationCode":"function inResponseToMatches(samlResponseBase64, expectedRequestId) {\n  if (!expectedRequestId) return true;\n  const xml = Buffer.from(samlResponseBase64, 'base64').toString('utf8');\n  const m = xml.match(/InResponseTo=[\"']([^\"']+)[\"']/i);\n  return !!m && m[1] === expectedRequestId;\n}\n// pre-check before calling validateSamlResponse to give a friendly restart-login UX","typeGuard":"function isSamlStateCookie(v) {\n  return typeof v === 'string' && /^[A-Za-z0-9_-]{8,}$/.test(v); // plausible request id\n}","tryCatchPattern":"try {\n  const profile = await validateSamlResponse(req, body, req.cookies.saml_state, settings);\n} catch (err) {\n  if (String(err.message).startsWith('InResponseTo mismatch')) {\n    clearSamlStateCookie(res);\n    return res.status(400).json({ error: 'Login request expired or replayed — start a new login' });\n  }\n  throw err;\n}","preventionTips":["Keep saml_state cookie settings compatible with a cross-site IdP POST (SameSite=None; Secure) so it survives the round trip.","Treat this error as 'restart the login flow': always clear state and redirect to /login rather than retrying the same assertion.","Never cache or replay SAMLResponse values in scripts, tests, or logs — each is bound to a single request ID.","If you support IdP-initiated SSO, route it to a path that skips the expectedRequestId check instead of sharing the SP-initiated callback."],"tags":["saml","sso","replay-protection","csrf","auth"],"backgroundTag":"saml-inresponseto-mismatch","analyzedSha":"90b52e06ffd666b7929554211474d01588f6b1f8","analyzedAt":"2026-08-30T21:05:45.952Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}