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.
| 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.
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.Email→PrivateProfile.email(current GitHub primary verified email)User.Username→Person.slugfirst_name→Person.firstNamelast_name→Person.lastName
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:
AuthnInstantis 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.SessionIndexis 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
AuthnContextClassRefis the fixed value above. It is not echoed back from the AuthnRequest'sRequestedAuthnContext— 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 defaultRequestedAuthnContextisPasswordProtectedTransport; the legacy connector assertedPasswordagainst 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. SessionNotOnOrAfteris omitted. The assertion'sConditions/@NotOnOrAfteralready bounds the assertion; we make no claim about IdP-session lifetime.
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.
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 ofSAML_ENTITY_ID(defaulthttps://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.SingleSignOnServicebinding(s) —urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST(for SP-initiated) andurn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect, both withLocation="https://<CFP_SITE_HOST>/api/saml/slack/sso"X509Certificate— the IdP cert fromSAML_CERTIFICATE- NameID formats supported:
urn:oasis:names:tc:SAML:2.0:nameid-format:persistent
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
Issueragainst it, so it must not change when the site moves hosts. In particular it does not followCFP_SITE_HOST: production runs atnext.codeforphilly.orgbefore cutover andcodeforphilly.orgafter, and the entity ID ishttps://codeforphilly.org/api/saml/slack/metadatathroughout. It is a URI by convention only — nothing fetches it. ChangeSAML_ENTITY_IDonly when deliberately re-registering the IdP with Slack. - The metadata
entityIDand the assertionIssuerare 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/@Locationis built fromCFP_SITE_HOST, so the metadata always points Slack at a URL that resolves to this deployment. WhenCFP_SITE_HOSTflips 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_HOSTother than the Slack-side values in the table.SLACK_TEAM_HOSTis Slack's host; it never appears in our entity ID or our endpoint URLs.
IdP-initiated sign-in — the member is on our site and wants to sign into Slack.
| 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. |
- Require a signed-in session. If not signed in → redirect to
/login?return=<encoded current URL>. - Validate the member is permitted (default: any
useraccountLevel; configurable via the sameuserIsPermittedhook the legacy code provided). - Build a SAML Response containing the NameID + attributes for the current Person.
- Sign the Response with the IdP private key.
- 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.
401 unauthenticated— no session (redirect to /login)403 forbiddenwitherror.code = "saml_not_permitted"— Person doesn't meet the membership requirement400 validation_failed— badchannelformat500 internal_errorwitherror.code = "saml_signing_failed"— IdP cert/key misconfiguration
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.
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) |
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 |
- Decode + parse the AuthnRequest (inflate first for the Redirect binding). Validate signature if Slack signs requests (configurable; usually no for Slack).
- 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. - Resolve the AuthnRequest's
AssertionConsumerServiceURLagainst Slack's documented ACS endpoint(s) — only Slack's ACS is accepted. - Build + sign a SAML Response as in
/launch. - 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.
400 validation_failedwith codesaml_request_invalid— malformed AuthnRequest (including a Redirect-binding payload that fails to inflate) or unrecognized ACS URL401 unauthenticated— no session (with resume-cookie flow as above)403 forbiddenwitherror.code = "saml_not_permitted"
The legacy IdentityConsumerTrait exposes a userIsPermitted static — code for Philly used the default ("any user accountLevel"). v1 preserves that hook conceptually:
- Default: any
useraccountLevel passes - Configurable per-consumer via a static
samlSlackUserIsPermitted: (person: Person) => booleanpredicate the deploy can override (rarely needed)
There's no v1 plan to vary this — keeping the hook just preserves the legacy escape valve.
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):
- Generate new key + cert
- Update Slack's admin UI with the new public cert
- Update the API's
SAML_PRIVATE_KEY/SAML_CERTIFICATEsecrets - Restart the API
Plan to rotate every 3 years before cert expiry; track in operational runbooks.
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.
- api/auth.md — GitHub OAuth flow that proves identity to our IdP
- data-model.md —
Person.slackSamlNameId,PrivateProfile.email - behaviors/authorization.md — session/JWT model
- screens/chat.md — the
/chatredirect that launches Slack - behaviors/account-migration.md — preserving identity continuity at cutover