Skip to content

fix(api): SAML AuthnStatement + Redirect-binding SSO endpoint - #174

Merged
themightychris merged 3 commits into
developfrom
fix/saml-authn-statement
Sep 10, 2026
Merged

fix(api): SAML AuthnStatement + Redirect-binding SSO endpoint#174
themightychris merged 3 commits into
developfrom
fix/saml-authn-statement

Conversation

@themightychris

Copy link
Copy Markdown
Member

Summary

Two conformance gaps in the Slack SAML IdP, both found while preparing to test against the live workspace.

1. Assertion had no AuthnStatement. The Web Browser SSO profile requires at least one, and Slack sends a RequestedAuthnContext, so this was the most likely first rejection. The assertion now carries one between Conditions and AttributeStatement:

  • AuthnInstant = the assertion's IssueInstant
  • SessionIndex = a fresh opaque id (distinct from the assertion ID)
  • AuthnContextClassRef = urn:oasis:names:tc:SAML:2.0:ac:classes:Password, fixed, never echoed from the request
  • SessionNotOnOrAfter omitted

Why Password and not PasswordProtectedTransport: the legacy Emergence SAML2 connector laddr used (emergence-saml2Emergence\SAML2\Connector::getSAMLAssertion) called setAuthnContext(SAML2_Constants::AC_PASSWORD) and setSessionIndex(generateId()), with simplesamlphp defaulting AuthnInstant to construction time and leaving SessionNotOnOrAfter null. AC_PASSWORD is ...:ac:classes:Password (confirmed in simplesamlphp/saml2 Constants.php). That class was accepted by this same workspace for years, so v1 matches it. Slack's default request asks for PasswordProtectedTransport, but the legacy evidence says the check is lenient; if the live test rejects on context it's a one-line constant + spec edit.

2. GET /api/saml/slack/sso was missing. Slack's "Test configuration" 404ed because Slack sends the AuthnRequest over HTTP-Redirect (GET ...?SAMLRequest=<deflate+base64>), which our metadata advertises at that Location but only POST was registered. The GET handler inflates the payload back to the plain-base64 form the POST binding carries, and both methods share one handleSpInitiatedSso (ACS allow-list, signed-in → auto-submit form, anonymous → resume cookie → /sso/resume). That keeps the resume cookie binding-agnostic; samlify's own 'redirect' parser is the same inflate followed by the same parse. RelayState is preserved.

Changes

  • specs/api/saml.md — new ### Authentication statement section; endpoints table lists GET and POST /sso; SP-initiated section describes both bindings as one flow (spec-first commit)
  • apps/api/src/saml/config.tsSLACK_AUTHN_CONTEXT_CLASS_REF; AuthnStatement in the template; AuthnInstant / SessionIndex / AuthnContextClassRef substitutions
  • apps/api/src/routes/saml.tsGET /api/saml/slack/sso; inflateRedirectBindingRequest; POST body extracted into shared handleSpInitiatedSso; SessionIndex minted alongside the Response/Assertion IDs
  • apps/api/tests/saml.test.ts — AuthnStatement shape + ordering; cryptographic signature verification against the metadata cert (via samlify.SamlLib.verifySignature) plus a tamper case; Redirect binding: signed-in, anonymous → resume, bad ACS, non-DEFLATEd payload
  • plans/saml-authn-statement.md — plan, closed out

Test plan

🤖 Generated with Claude Code

https://claude.ai/code/session_015jmpurQpBHXD9yLFEGUriR

themightychris added a commit that referenced this pull request Sep 10, 2026
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jmpurQpBHXD9yLFEGUriR
themightychris and others added 3 commits September 10, 2026 02:36
The SAML assertion spec described Subject, Conditions and the attribute
set but never an AuthnStatement, and the implementation followed it. The
Web Browser SSO profile requires at least one, and Slack's SP config
sends a RequestedAuthnContext, so an assertion without one is the most
likely rejection when we test against the real workspace. Spec the
statement: AuthnInstant = assertion issue time, a fresh SessionIndex,
and a fixed AuthnContextClassRef of ...:ac:classes:Password. That class
matches what the legacy Emergence SAML2 connector emitted
(setAuthnContext(AC_PASSWORD)) against this same workspace, which is
worth more than the marginally more precise PasswordProtectedTransport.

Slack's live "Test configuration" also failed with a 404: it sends the
AuthnRequest over HTTP-Redirect (GET with a DEFLATEd SAMLRequest in the
query), and although our metadata advertises that binding at the /sso
Location, the spec only defined POST. Add GET to the endpoints table and
describe both bindings as one flow that differs only in transport.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jmpurQpBHXD9yLFEGUriR
Two gaps found while preparing to test the IdP against the live Slack
workspace, both in the SP-initiated path.

The assertion carried Subject, Conditions and AttributeStatement but no
AuthnStatement, which the Web Browser SSO profile requires and which
Slack's RequestedAuthnContext gives it a reason to check. Add one to the
login response template between Conditions and AttributeStatement, with
AuthnInstant = the assertion issue time, a fresh SessionIndex from the
same id source as the Response/Assertion IDs, and the fixed class
urn:oasis:names:tc:SAML:2.0:ac:classes:Password. That class is what the
legacy Emergence SAML2 connector asserted against this workspace
(setAuthnContext(AC_PASSWORD)), so existing Slack accounts see the same
context they were established under. The placeholders ride the existing
customTagReplacement path; samlify signs after that callback returns, so
the statement lands inside the signed subtree.

Slack's "Test configuration" then 404ed: Slack sends the AuthnRequest
over HTTP-Redirect (GET with a DEFLATEd SAMLRequest in the query), and
while the metadata advertised that binding at /sso, only POST was
registered. Add GET /api/saml/slack/sso. Rather than call samlify's
'redirect' parser and teach the resume cookie about bindings, inflate at
the edge back to the plain-base64 form the POST binding carries and run
both through one handleSpInitiatedSso; samlify's redirect flow is that
same inflate followed by the same parser, so nothing is lost and the
cookie's samlRequest claim keeps a single shape. Fold spaces back to '+'
in the query value, since a sender that leaves base64 '+' unescaped has
it URL-decoded to a space.

Tests cover the AuthnStatement (position, ClassRef, AuthnInstant <=
IssueInstant, SessionIndex distinct from the assertion ID), verify the
signature cryptographically against the metadata endpoint's cert and
show that tampering with the ClassRef breaks it, and exercise the
Redirect binding for signed-in, anonymous (through /sso/resume), bad ACS
and non-DEFLATEd payloads.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jmpurQpBHXD9yLFEGUriR
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jmpurQpBHXD9yLFEGUriR
@themightychris
themightychris force-pushed the fix/saml-authn-statement branch from 983276b to 78ab9be Compare September 10, 2026 06:36
@themightychris
themightychris merged commit c9e2802 into develop Sep 10, 2026
1 check passed
@themightychris
themightychris deleted the fix/saml-authn-statement branch September 10, 2026 06:40
@themightychris themightychris mentioned this pull request Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant