Skip to content

feat(saml-idp): Slack SSO via signed SAML assertions#49

Merged
themightychris merged 6 commits into
mainfrom
feat/saml-idp
May 17, 2026
Merged

feat(saml-idp): Slack SSO via signed SAML assertions#49
themightychris merged 6 commits into
mainfrom
feat/saml-idp

Conversation

@themightychris
Copy link
Copy Markdown
Member

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 /login
  • POST /api/saml/slack/sso — SP-initiated SSO; validates AssertionConsumerServiceURL against Slack's ACS endpoint; if anonymous, stashes the AuthnRequest in a 10-minute signed cookie (cfp_saml_resume) and bounces through /login
  • GET /api/saml/slack/sso/resume — continuation after the login round-trip; replays the stashed AuthnRequest

NameID.Value comes from Person.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) and NameID format (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 customTagReplacement callback because samlify's default createLoginResponse forces NameID = user.email and ignores caller-supplied loginResponseTemplate.context.

Adds SLACK_TEAM_HOST env var (default codeforphilly.slack.com), to be shared with the future /chat redirect.

Test plan

  • GET /api/saml/slack/metadata returns parseable SAML 2.0 IdP metadata (root element, entityID, IDPSSODescriptor, two SingleSignOnService bindings, X509Certificate, NameIDFormat)
  • GET /api/saml/slack/launch (signed-in) returns an auto-submit form whose decoded SAMLResponse contains the right NameID (= slackSamlNameId), NameQualifier, SPNameQualifier, the four attributes with the right values, and a <ds:Signature> block
  • GET /api/saml/slack/launch?channel=phlask carries phlask as RelayState
  • GET /api/saml/slack/launch?channel=<bad> → 422 validation_failed
  • GET /api/saml/slack/launch anonymous → 302 to /login?return=…
  • POST /api/saml/slack/sso (anonymous, valid AuthnRequest) sets the resume cookie and redirects to /login
  • POST /api/saml/slack/sso (signed-in) returns auto-submit form targeting Slack's ACS with RelayState echoed
  • POST /api/saml/slack/sso with foreign AssertionConsumerServiceURL → 422
  • GET /api/saml/slack/metadata without SAML_PRIVATE_KEY → 500 saml_signing_failed
  • End-to-end against a real Slack workspace (deferred — requires deploy + admin coordination)
  • Captured-legacy-assertion diff (deferred — requires staging access)
  • docs/operations/update-saml2-certificate.md (deferred — separate ops doc)

🤖 Generated with Claude Code

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
@themightychris themightychris merged commit cc7cb48 into main May 17, 2026
1 check passed
@themightychris themightychris deleted the feat/saml-idp branch May 17, 2026 02:54
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