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 |
POST |
/api/saml/slack/sso |
user | SP-initiated SSO callback — handles AuthnRequest from Slack |
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
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, e.g.,https://codeforphilly.org/api/saml/slack/metadataSingleSignOnServicebinding(s) —urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST(for SP-initiated) andurn:oasis:names:tc:SAML:2.0:bindings:HTTP-RedirectX509Certificate— the IdP cert fromSAML_CERTIFICATE- NameID formats supported:
urn:oasis:names:tc:SAML:2.0:nameid-format:persistent
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.
application/x-www-form-urlencoded:
| Field | Required | Notes |
|---|---|---|
SAMLRequest |
yes | base64-encoded SAML AuthnRequest XML |
RelayState |
no | opaque value Slack wants us to echo back |
- Decode + parse the AuthnRequest. 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.
400 validation_failedwith codesaml_request_invalid— malformed AuthnRequest 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:
| Env var | Purpose |
|---|---|
SAML_PRIVATE_KEY |
PEM-encoded RSA private key for signing assertions |
SAML_CERTIFICATE |
PEM-encoded X.509 cert (the public half) |
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