Skip to content

Latest commit

 

History

History
213 lines (146 loc) · 14.3 KB

File metadata and controls

213 lines (146 loc) · 14.3 KB

API: SAML Identity Provider (for Slack)

The site is a SAML 2.0 Identity Provider for Code for Philly's Slack workspace. This continues the long-standing arrangement where members sign in to Slack via codeforphilly.org. The legacy laddr code lives in JarvusInnovations/emergence-slack; the v1 design preserves its NameID format so existing Slack accounts stay continuously valid through the migration.

GitHub OAuth is how a member proves identity to the new site. SAML is how the site asserts that identity to Slack. The two flows compose: a member's Slack sign-in triggers our SAML flow, which requires an active GitHub-OAuth-backed session.

Endpoints

Method Path Auth Summary
GET /api/saml/slack/metadata public IdP metadata XML for Slack to consume
GET /api/saml/slack/launch user IdP-initiated SSO — site → Slack
GET /api/saml/slack/sso user SP-initiated SSO callback — AuthnRequest from Slack via the HTTP-Redirect binding
POST /api/saml/slack/sso user SP-initiated SSO callback — AuthnRequest from Slack via the HTTP-POST binding

For the existing /chat redirect that Slack-launches members into channels, see screens/chat.md. The SAML endpoints live under /api/saml/slack/* because the v1 design leaves room for additional SAML SP integrations later.

Identity assertion

Per emergence-slack's Connector.php, the legacy laddr code asserts:

NameID:
  Format          urn:oasis:names:tc:SAML:2.0:nameid-format:persistent
  NameQualifier   <teamHost>                                        e.g., codeforphilly.slack.com
  SPNameQualifier https://slack.com
  Value           <Person.Username>                                  the user's username, NOT email

Attributes:
  User.Email      <Person.Email>
  User.Username   <Person.Username>
  first_name      <Person.FirstName>
  last_name       <Person.LastName>

v1 preserves every one of these field values and the NameID format, so existing Slack accounts continue to authenticate against the same identifier they always have.

The NameID Value is Person.slackSamlNameId (per data-model.md) — populated from slug at Person creation, immutable after, so slug renames don't invalidate the user's Slack identity. The migration script populates slackSamlNameId = slug for every imported Person at cutover.

The attribute values come from:

  • User.EmailPrivateProfile.email (current GitHub primary verified email)
  • User.UsernamePerson.slug
  • first_namePerson.firstName
  • last_namePerson.lastName

Authentication statement

Every assertion carries exactly one <saml:AuthnStatement> — the Web Browser SSO profile (saml-profiles §4.1.4.2) requires at least one, and an assertion without it is a valid rejection reason for any SP. It sits between <saml:Conditions> and <saml:AttributeStatement> (schema order: Subject, Conditions, then statements). The legacy connector emitted one via simplesamlphp's Assertion (setSessionIndex(generateId()) + setAuthnContext(SAML2_Constants::AC_PASSWORD)); v1 preserves that shape:

AuthnStatement:
  AuthnInstant          <assertion IssueInstant>                                 the moment this assertion was issued
  SessionIndex          <fresh opaque id>                                        one per assertion; not the assertion ID
  AuthnContext/
    AuthnContextClassRef  urn:oasis:names:tc:SAML:2.0:ac:classes:Password        fixed — see below

Rules:

  • AuthnInstant is the assertion's issue time, not the time the member's underlying session was established. We don't track the original sign-in instant in the JWT, and re-asserting "now" is what the legacy connector did.
  • SessionIndex is a fresh opaque identifier per assertion. Slack never sends us a LogoutRequest, so nothing correlates on it; it exists to satisfy the profile. Use a fresh id rather than reusing the assertion ID so the two values stay independently meaningful.
  • The AuthnContextClassRef is the fixed value above. It is not echoed back from the AuthnRequest's RequestedAuthnContext — a member proves identity to us via GitHub OAuth or the legacy password path, and we describe that once, the same way for every SP-initiated and IdP-initiated response. Slack's default RequestedAuthnContext is PasswordProtectedTransport; the legacy connector asserted Password against that same workspace for years without rejection, and matching the value existing Slack accounts were established under is worth more than the marginally more precise class.
  • SessionNotOnOrAfter is omitted. The assertion's Conditions/@NotOnOrAfter already bounds the assertion; we make no claim about IdP-session lifetime.

GET /api/saml/slack/metadata

Returns the IdP's SAML metadata XML, signed with the IdP cert. Slack consumes this once during admin setup; we generally don't re-fetch.

Response — 200

Content-Type: application/samlmetadata+xml; charset=utf-8

<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" ...>
  ...
</EntityDescriptor>

The metadata declares:

  • entityID — our IdP entity ID: the value of SAML_ENTITY_ID (default https://codeforphilly.org/api/saml/slack/metadata). See IdP identity and hosts — this is a stable logical identifier, not a reflection of whatever host is serving the request.
  • SingleSignOnService binding(s) — urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST (for SP-initiated) and urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect, both with Location="https://<CFP_SITE_HOST>/api/saml/slack/sso"
  • X509Certificate — the IdP cert from SAML_CERTIFICATE
  • NameID formats supported: urn:oasis:names:tc:SAML:2.0:nameid-format:persistent

IdP identity and hosts

Three distinct hosts/identifiers appear in the IdP's output, and each comes from its own setting. They must never be conflated:

Value Source Where it appears
IdP entity ID SAML_ENTITY_ID (default https://codeforphilly.org/api/saml/slack/metadata) Metadata entityID; <saml:Issuer> on every Response and every Assertion
Our SSO endpoint URLs https://<CFP_SITE_HOST>/api/saml/slack/... Metadata SingleSignOnService/@Location (both bindings)
Slack workspace host SLACK_TEAM_HOST (default codeforphilly.slack.com) ACS URL (https://<SLACK_TEAM_HOST>/sso/saml) — Destination, Recipient, and the auto-submit form action; NameID NameQualifier; the /launch and /chat redirect target

Rules:

  • The entity ID is a stable logical identifier. Slack stores it at setup time and matches every assertion's Issuer against it, so it must not change when the site moves hosts. In particular it does not follow CFP_SITE_HOST: production runs at next.codeforphilly.org before cutover and codeforphilly.org after, and the entity ID is https://codeforphilly.org/api/saml/slack/metadata throughout. It is a URI by convention only — nothing fetches it. Change SAML_ENTITY_ID only when deliberately re-registering the IdP with Slack.
  • The metadata entityID and the assertion Issuer are the same value, read from the same setting. A metadata document that advertises one entity ID while assertions carry another is a bug.
  • Endpoint URLs follow the host actually serving the API. SingleSignOnService/@Location is built from CFP_SITE_HOST, so the metadata always points Slack at a URL that resolves to this deployment. When CFP_SITE_HOST flips at cutover the metadata's endpoint URLs change and Slack's IdP config should be refreshed from the metadata URL — but the entity ID (and therefore the trust relationship) is untouched.
  • Nothing IdP-side is ever built from SLACK_TEAM_HOST other than the Slack-side values in the table. SLACK_TEAM_HOST is Slack's host; it never appears in our entity ID or our endpoint URLs.

GET /api/saml/slack/launch

IdP-initiated sign-in — the member is on our site and wants to sign into Slack.

Query parameters

Param Required Notes
channel no Slack channel name to land in after sign-in (e.g., general, phlask). Validated against ^[a-z0-9][a-z0-9_-]{0,80}$.
redir no Slack post-login path (alternative to channel). Default: workspace home.

Behavior

  1. Require a signed-in session. If not signed in → redirect to /login?return=<encoded current URL>.
  2. Validate the member is permitted (default: any user accountLevel; configurable via the same userIsPermitted hook the legacy code provided).
  3. Build a SAML Response containing the NameID + attributes for the current Person.
  4. Sign the Response with the IdP private key.
  5. Redirect the browser to Slack's ACS URL via an HTML auto-submitting form (HTTP-POST binding), carrying:
    • SAMLResponse (base64-encoded signed XML)
    • RelayState — the channel/redir path so Slack lands the user in the right place

The destination URL inside the Response includes the redir so Slack's POST endpoint sees it.

Errors

  • 401 unauthenticated — no session (redirect to /login)
  • 403 forbidden with error.code = "saml_not_permitted" — Person doesn't meet the membership requirement
  • 400 validation_failed — bad channel format
  • 500 internal_error with error.code = "saml_signing_failed" — IdP cert/key misconfiguration

GET | POST /api/saml/slack/sso

SP-initiated sign-in — Slack received a request from a member who wants to sign in, sent us a SAML AuthnRequest. We complete authentication and return a SAML Response.

The metadata advertises this one Location under both SingleSignOnService bindings, so the endpoint accepts the AuthnRequest either way. Slack uses HTTP-Redirect (GET) when a member starts sign-in from Slack and when an admin runs "Test configuration"; POST is accepted for the HTTP-POST binding. The two differ only in how SAMLRequest is transported; everything after decoding is one flow.

Request — HTTP-Redirect binding (GET)

Query string (saml-bindings §3.4.4.1, DEFLATE encoding):

Param Required Notes
SAMLRequest yes raw-DEFLATEd, then base64-encoded, then URL-encoded SAML AuthnRequest XML
RelayState no opaque value Slack wants us to echo back
SigAlg, Signature no detached signature — ignored unless request signing is enabled (it isn't for Slack)

Request — HTTP-POST binding (POST)

application/x-www-form-urlencoded:

Field Required Notes
SAMLRequest yes base64-encoded SAML AuthnRequest XML (no DEFLATE)
RelayState no opaque value Slack wants us to echo back

Behavior

  1. Decode + parse the AuthnRequest (inflate first for the Redirect binding). Validate signature if Slack signs requests (configurable; usually no for Slack).
  2. Require a signed-in session. If not → store the AuthnRequest in a short-lived signed cookie, redirect to /login?return=/api/saml/slack/sso?resume=1. After login the user comes back here and the AuthnRequest replays from the cookie.
  3. Resolve the AuthnRequest's AssertionConsumerServiceURL against Slack's documented ACS endpoint(s) — only Slack's ACS is accepted.
  4. Build + sign a SAML Response as in /launch.
  5. POST back to Slack's ACS via the auto-submitting form, including RelayState. The Response always goes back over HTTP-POST regardless of which binding carried the request — Slack's ACS only accepts POST.

Errors

  • 400 validation_failed with code saml_request_invalid — malformed AuthnRequest (including a Redirect-binding payload that fails to inflate) or unrecognized ACS URL
  • 401 unauthenticated — no session (with resume-cookie flow as above)
  • 403 forbidden with error.code = "saml_not_permitted"

Account-level hook

The legacy IdentityConsumerTrait exposes a userIsPermitted static — code for Philly used the default ("any user accountLevel"). v1 preserves that hook conceptually:

  • Default: any user accountLevel passes
  • Configurable per-consumer via a static samlSlackUserIsPermitted: (person: Person) => boolean predicate the deploy can override (rarely needed)

There's no v1 plan to vary this — keeping the hook just preserves the legacy escape valve.

Cert + key rotation

The cert + private key are env-injected, alongside the IdP identity:

Env var Purpose
SAML_PRIVATE_KEY PEM-encoded RSA private key for signing assertions
SAML_CERTIFICATE PEM-encoded X.509 cert (the public half)
SAML_ENTITY_ID Optional. The IdP entity ID / assertion Issuer (default https://codeforphilly.org/api/saml/slack/metadata). Not a secret. See IdP identity and hosts.

Slack's admin panel holds the matching public cert. Rotation is a coordinated procedure (per the legacy docs/operations/update-saml2-certificate.md):

  1. Generate new key + cert
  2. Update Slack's admin UI with the new public cert
  3. Update the API's SAML_PRIVATE_KEY / SAML_CERTIFICATE secrets
  4. Restart the API

Plan to rotate every 3 years before cert expiry; track in operational runbooks.

Cutover continuity

Existing Slack accounts are tied to NameID.Value = <laddr Username>. Because the rewrite preserves slugs (per behaviors/slug-handles.md) AND populates slackSamlNameId from slug at migration time, every existing Slack account continues to authenticate the same way on cutover day.

After cutover, slug renames don't break Slack identity (immutable slackSamlNameId). New Persons created post-cutover get slackSamlNameId populated at creation; their Slack-side identity binds on first Slack sign-in.

Coordinates with