feat(saml-idp): Slack SSO via signed SAML assertions#49
Merged
Conversation
This was referenced May 17, 2026
themightychris
added a commit
that referenced
this pull request
May 17, 2026
npm install -w apps/api samlify
Slack POSTs the SAMLRequest as application/x-www-form-urlencoded; @fastify/formbody parses it. npm install -w apps/api @fastify/formbody
Adds the three IdP endpoints documented in specs/api/saml.md: GET /api/saml/slack/metadata - signed IdP metadata XML for Slack admin setup GET /api/saml/slack/launch - IdP-initiated SSO with auto-submit form to ACS POST /api/saml/slack/sso - SP-initiated SSO (AuthnRequest -> Response) GET /api/saml/slack/sso/resume - continuation after /login round-trip The legacy NameID format (persistent, NameQualifier=team host) and attribute set (User.Email, User.Username, first_name, last_name) are preserved verbatim so existing Slack accounts authenticate continuously through cutover. NameID.Value comes from Person.slackSamlNameId (immutable after creation per data-model.md) so a slug rename never breaks Slack identity. Anonymous callers to /launch or /sso are bounced through /login with the return path preserved; the SP-initiated flow stashes the AuthnRequest in a short-lived signed cookie (cfp_saml_resume) and replays it from the resume endpoint once a session lands. samlify's default `createLoginResponse` forces NameID = user.email and ignores the caller-supplied loginResponseTemplate.context; we go through its `customTagReplacement` callback to substitute the right NameID (slackSamlNameId) plus NameQualifier / SPNameQualifier / per-attribute placeholders that samlify's default tvalue map omits. Adds the SLACK_TEAM_HOST env var (defaulting to codeforphilly.slack.com), shared with the future /chat redirect handler.
Generates a transient self-signed cert via openssl at test setup, seeds a
Person + PrivateProfile, asserts:
- metadata XML is parseable SAML 2.0 with required descriptors
- /launch (signed-in) returns auto-submit form whose SAMLResponse contains
the spec'd NameID quartet + the four attributes + a Signature
- /launch?channel=phlask carries the channel as RelayState
- /launch?channel=BAD_CASE -> 422
- /launch (anonymous) -> 302 to /login
- /sso (anonymous, valid AuthnRequest) -> resume cookie + 302
- /sso (signed-in) -> auto-submit form back to Slack ACS
- /sso with foreign AssertionConsumerServiceURL -> 422
- /metadata without SAML_PRIVATE_KEY -> 500 saml_signing_failed
c5a361c to
08dd014
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements
plans/saml-idp.md— the SAML 2.0 IdP endpoints for codeforphilly.slack.com per specs/api/saml.md.GET /api/saml/slack/metadata— signed IdP metadata XML (Slack admin consumes once during setup)GET /api/saml/slack/launch— IdP-initiated SSO; signed-in users get an auto-submitting POST form to Slack's ACS; anonymous users redirect through/loginPOST /api/saml/slack/sso— SP-initiated SSO; validatesAssertionConsumerServiceURLagainst Slack's ACS endpoint; if anonymous, stashes theAuthnRequestin a 10-minute signed cookie (cfp_saml_resume) and bounces through/loginGET /api/saml/slack/sso/resume— continuation after the login round-trip; replays the stashedAuthnRequestNameID.Valuecomes fromPerson.slackSamlNameId(immutable per data-model.md) so slug renames never invalidate Slack identity. The legacy attribute set (User.Email,User.Username,first_name,last_name) andNameIDformat (persistent,NameQualifier = <team host>,SPNameQualifier = https://slack.com) are preserved verbatim — existing Slack accounts authenticate continuously through cutover.Library: samlify v2.13.0. We go through its
customTagReplacementcallback because samlify's defaultcreateLoginResponseforcesNameID = user.emailand ignores caller-suppliedloginResponseTemplate.context.Adds
SLACK_TEAM_HOSTenv var (defaultcodeforphilly.slack.com), to be shared with the future/chatredirect.Test plan
GET /api/saml/slack/metadatareturns parseable SAML 2.0 IdP metadata (root element,entityID,IDPSSODescriptor, twoSingleSignOnServicebindings,X509Certificate,NameIDFormat)GET /api/saml/slack/launch(signed-in) returns an auto-submit form whose decoded SAMLResponse contains the rightNameID(=slackSamlNameId),NameQualifier,SPNameQualifier, the four attributes with the right values, and a<ds:Signature>blockGET /api/saml/slack/launch?channel=phlaskcarriesphlaskas RelayStateGET /api/saml/slack/launch?channel=<bad>→ 422 validation_failedGET /api/saml/slack/launchanonymous → 302 to/login?return=…POST /api/saml/slack/sso(anonymous, valid AuthnRequest) sets the resume cookie and redirects to/loginPOST /api/saml/slack/sso(signed-in) returns auto-submit form targeting Slack's ACS withRelayStateechoedPOST /api/saml/slack/ssowith foreignAssertionConsumerServiceURL→ 422GET /api/saml/slack/metadatawithoutSAML_PRIVATE_KEY→ 500saml_signing_faileddocs/operations/update-saml2-certificate.md(deferred — separate ops doc)🤖 Generated with Claude Code