diff --git a/apps/api/package.json b/apps/api/package.json
index 4ea5243..190a5d6 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -21,6 +21,7 @@
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/env": "^6.0.0",
+ "@fastify/formbody": "^8.0.2",
"@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.1.3",
"@fastify/swagger": "^9.7.0",
@@ -30,6 +31,7 @@
"fastify": "^5.8.5",
"gitsheets": "^1.0.3",
"jose": "^6.2.3",
+ "samlify": "^2.13.0",
"uuidv7": "^1.2.1",
"zod": "^4.4.3"
},
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index cbef04d..be15fb6 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -21,6 +21,7 @@ import Fastify, { type FastifyInstance, type FastifyServerOptions } from 'fastif
import fastifyEnv from '@fastify/env';
import fastifyCors from '@fastify/cors';
import fastifyCookie from '@fastify/cookie';
+import fastifyFormbody from '@fastify/formbody';
import fastifySwagger from '@fastify/swagger';
import fastifySwaggerUi from '@fastify/swagger-ui';
@@ -44,6 +45,7 @@ import { projectBuzzRoutes } from './routes/projects-buzz.js';
import { helpWantedRoutes } from './routes/projects-help-wanted.js';
import { projectMembershipRoutes } from './routes/projects-members.js';
import { previewRoutes } from './routes/preview.js';
+import { samlRoutes } from './routes/saml.js';
declare module 'fastify' {
interface FastifyInstance {
@@ -95,6 +97,10 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+/**
+ * Render the HTTP-POST binding auto-submit form per saml-bindings §3.5.4.
+ * Includes a fallback button for browsers with JS disabled.
+ */
+function renderPostForm(opts: {
+ readonly actionUrl: string;
+ readonly samlResponse: string;
+ readonly relayState?: string;
+}): string {
+ const hiddenFields: string[] = [
+ ``,
+ ];
+ if (opts.relayState !== undefined && opts.relayState !== '') {
+ hiddenFields.push(
+ ``,
+ );
+ }
+ return `
+
Signing into Slack
+
+
+
+`;
+}
+
+interface SamlContext {
+ readonly entities: SlackSamlEntities;
+}
+
+/**
+ * Lazy SAML entity construction — built once at first use rather than at app
+ * boot, so missing cert/key only fails the SAML endpoints (not the whole
+ * process boot). The token check on every call keeps the lookup cheap.
+ */
+function getSamlContext(fastify: FastifyInstance): SamlContext {
+ const cached = (fastify as FastifyInstance & { _samlCtx?: SamlContext })._samlCtx;
+ if (cached) return cached;
+
+ const cfg = fastify.config;
+ if (!cfg.SAML_PRIVATE_KEY || !cfg.SAML_CERTIFICATE) {
+ throw new ApiValidationError('SAML IdP is not configured');
+ }
+
+ const base = `https://${cfg.SLACK_TEAM_HOST}`.replace('https://', '');
+ const issuerHost = base;
+ // Fallback to the team host for the metadata entity ID if we can't see
+ // the inbound request origin. Per spec the entityID is our own URL —
+ // we'll prefer the request origin when building responses.
+ const ctx: SamlContext = {
+ entities: buildSlackSamlEntities({
+ privateKey: cfg.SAML_PRIVATE_KEY,
+ certificate: cfg.SAML_CERTIFICATE,
+ entityId: `https://${issuerHost}/api/saml/slack/metadata`,
+ ssoLoginPostUrl: `https://${issuerHost}/api/saml/slack/sso`,
+ ssoLoginRedirectUrl: `https://${issuerHost}/api/saml/slack/sso`,
+ slackTeamHost: cfg.SLACK_TEAM_HOST,
+ }),
+ };
+
+ (fastify as FastifyInstance & { _samlCtx?: SamlContext })._samlCtx = ctx;
+ return ctx;
+}
+
+/**
+ * Build the per-request assertion user from a signed-in Person + their private
+ * profile. The hard invariant — `slackSamlNameId` is non-null — is enforced
+ * here so the SAML pipeline only sees a fully-populated user.
+ */
+function buildAssertionUser(opts: {
+ readonly person: Person;
+ readonly profile: PrivateProfile;
+}): SlackAssertionUser {
+ const { person, profile } = opts;
+ if (!person.slackSamlNameId) {
+ throw new ApiValidationError(
+ 'Person is missing slackSamlNameId; SAML SSO cannot proceed',
+ );
+ }
+ return {
+ nameId: person.slackSamlNameId,
+ email: profile.email,
+ username: person.slug,
+ firstName: person.firstName ?? '',
+ lastName: person.lastName ?? '',
+ };
+}
+
+/**
+ * Build the `customTagReplacement` callback for samlify's createLoginResponse.
+ *
+ * samlify's default substitution forces `NameID = user.email` and ignores the
+ * caller-supplied `loginResponseTemplate.context` unless this callback is
+ * provided. We use the callback to:
+ *
+ * - drop the right NameID (slackSamlNameId, not email) into the assertion
+ * - fill in NameQualifier / SPNameQualifier (samlify's default skips both)
+ * - substitute the per-attribute placeholder tags built by samlify's
+ * `attributeStatementBuilder` (e.g. `{attrEmail}`, `{attrUsername}`).
+ */
+function buildCustomTagReplacement(opts: {
+ readonly user: SlackAssertionUser;
+ readonly slackTeamHost: string;
+ readonly issuerEntityId: string;
+ readonly inResponseTo: string;
+ readonly generateID: () => string;
+}): (template: string) => { id: string; context: string } {
+ return (template) => {
+ const id = opts.generateID();
+ const assertionId = opts.generateID();
+ const subs = buildResponseSubstitutions({
+ user: opts.user,
+ slackTeamHost: opts.slackTeamHost,
+ issuerEntityId: opts.issuerEntityId,
+ inResponseTo: opts.inResponseTo,
+ });
+ const fullSubs: Record = {
+ ID: id,
+ AssertionID: assertionId,
+ ...subs,
+ };
+ return { id, context: replaceTagsByValue(template, fullSubs) };
+ };
+}
+
+/**
+ * Reject responses that would target an ACS we don't recognise. We treat the
+ * configured `.slack.com/sso/saml` URL as the only valid destination.
+ */
+function assertAcsAllowed(acsUrl: string, slackTeamHost: string): void {
+ const expected = slackAcsUrl(slackTeamHost);
+ if (acsUrl !== expected) {
+ throw new ApiValidationError(
+ `Unrecognised AssertionConsumerServiceURL: ${acsUrl}`,
+ { AssertionConsumerServiceURL: 'must match Slack ACS endpoint' },
+ );
+ }
+}
+
+async function loadPersonAndProfile(
+ fastify: FastifyInstance,
+ personId: string,
+): Promise<{ person: Person; profile: PrivateProfile }> {
+ const person = (await fastify.store.public.people.queryFirst({ id: personId })) as
+ | Person
+ | undefined;
+ if (!person) {
+ throw new UnauthenticatedError('Person not found', 'unauthenticated');
+ }
+ if (!defaultSamlSlackUserIsPermitted(person)) {
+ throw new ForbiddenError('Not permitted to access Slack', 'saml_not_permitted');
+ }
+ const profile = await fastify.store.private.getProfile(person.id);
+ if (!profile) {
+ // A signed-in Person without a private profile shouldn't happen — every
+ // sign-in path provisions one. Treat it as a permission gap.
+ throw new ForbiddenError(
+ 'Private profile missing for signed-in user',
+ 'saml_not_permitted',
+ );
+ }
+ return { person, profile };
+}
+
+// ---------------------------------------------------------------------------
+// Routes
+// ---------------------------------------------------------------------------
+
+export async function samlRoutes(fastify: FastifyInstance): Promise {
+ // -------------------------------------------------------------------------
+ // GET /api/saml/slack/metadata
+ // -------------------------------------------------------------------------
+
+ fastify.get(
+ '/api/saml/slack/metadata',
+ {
+ schema: {
+ tags: ['saml'],
+ summary: 'Slack IdP metadata XML',
+ description:
+ 'Returns the signed SAML 2.0 IdP metadata XML for Slack to consume during admin setup.',
+ },
+ },
+ async (request, reply) => {
+ const cfg = fastify.config;
+ if (!cfg.SAML_PRIVATE_KEY || !cfg.SAML_CERTIFICATE) {
+ return reply.code(500).send(
+ errorResponse(
+ 'saml_signing_failed',
+ 'SAML IdP is not configured',
+ (request as FastifyRequest & { traceId?: string }).traceId,
+ ),
+ );
+ }
+ const { entities } = getSamlContext(fastify);
+ return reply
+ .header('Content-Type', 'application/samlmetadata+xml; charset=utf-8')
+ .send(entities.metadataXml);
+ },
+ );
+
+ // -------------------------------------------------------------------------
+ // GET /api/saml/slack/launch — IdP-initiated SSO
+ // -------------------------------------------------------------------------
+
+ fastify.get(
+ '/api/saml/slack/launch',
+ {
+ schema: {
+ tags: ['saml'],
+ summary: 'IdP-initiated Slack sign-in',
+ querystring: {
+ type: 'object',
+ properties: {
+ channel: { type: 'string' },
+ redir: { type: 'string' },
+ },
+ },
+ },
+ },
+ async (request, reply) => {
+ const cfg = fastify.config;
+
+ // Anonymous → bounce through /login preserving the current URL.
+ if (!request.session.personId) {
+ const here = selfUrl(request);
+ return reply.redirect(`/login?return=${encodeURIComponent(here)}`);
+ }
+
+ const query = request.query as { channel?: string; redir?: string };
+ if (query.channel !== undefined && query.channel !== '') {
+ if (!CHAT_CHANNEL_REGEX.test(query.channel)) {
+ throw new ApiValidationError('Invalid channel name', { channel: 'invalid format' });
+ }
+ }
+
+ const { entities } = getSamlContext(fastify);
+ const { person, profile } = await loadPersonAndProfile(fastify, request.session.personId);
+
+ const user = buildAssertionUser({ person, profile });
+
+ const customTagReplacement = buildCustomTagReplacement({
+ user,
+ slackTeamHost: cfg.SLACK_TEAM_HOST,
+ issuerEntityId: entities.entityId,
+ inResponseTo: '',
+ generateID: () => `_${cryptoRandomId()}`,
+ });
+
+ // IdP-initiated flow — empty extract per saml-profiles §4.1.5 (unsolicited
+ // responses omit InResponseTo).
+ const bindingCtx = await entities.idp.createLoginResponse(
+ entities.sp,
+ { extract: {} },
+ 'post',
+ // samlify normally reads NameID from `user.email`; we feed it the right
+ // value through the customTagReplacement callback below so this `user`
+ // bag is unused on the hot path.
+ {},
+ { relayState: query.redir ?? query.channel ?? '', customTagReplacement },
+ );
+
+ // PostBindingContext.context holds the base64-encoded signed Response.
+ const samlResponse = bindingCtx.context;
+ const relayState = 'relayState' in bindingCtx ? bindingCtx.relayState : query.redir;
+ const actionUrl =
+ 'entityEndpoint' in bindingCtx && typeof bindingCtx.entityEndpoint === 'string'
+ ? bindingCtx.entityEndpoint
+ : entities.acsUrl;
+
+ return reply
+ .header('Content-Type', 'text/html; charset=utf-8')
+ .send(
+ renderPostForm({
+ actionUrl,
+ samlResponse,
+ relayState: relayState ?? undefined,
+ }),
+ );
+ },
+ );
+
+ // -------------------------------------------------------------------------
+ // POST /api/saml/slack/sso — SP-initiated SSO
+ // -------------------------------------------------------------------------
+
+ fastify.post(
+ '/api/saml/slack/sso',
+ {
+ schema: {
+ tags: ['saml'],
+ summary: 'SP-initiated Slack sign-in (AuthnRequest)',
+ body: {
+ type: 'object',
+ properties: {
+ SAMLRequest: { type: 'string' },
+ RelayState: { type: 'string' },
+ },
+ required: ['SAMLRequest'],
+ },
+ },
+ },
+ async (request, reply) => {
+ const cfg = fastify.config;
+ if (!cfg.SAML_PRIVATE_KEY || !cfg.SAML_CERTIFICATE) {
+ return reply.code(500).send(
+ errorResponse(
+ 'saml_signing_failed',
+ 'SAML IdP is not configured',
+ (request as FastifyRequest & { traceId?: string }).traceId,
+ ),
+ );
+ }
+
+ const body = request.body as { SAMLRequest?: string; RelayState?: string };
+ const samlRequestB64 = body.SAMLRequest ?? '';
+ const relayState = body.RelayState ?? '';
+ if (!samlRequestB64) {
+ throw new ApiValidationError('SAMLRequest is required', {
+ SAMLRequest: 'required',
+ });
+ }
+
+ const { entities } = getSamlContext(fastify);
+
+ // Parse the AuthnRequest to extract its ID + AssertionConsumerServiceURL.
+ let parsed: Awaited>;
+ try {
+ parsed = await entities.idp.parseLoginRequest(entities.sp, 'post', {
+ body: { SAMLRequest: samlRequestB64 },
+ });
+ } catch (err) {
+ fastify.log.warn({ err }, 'SAML AuthnRequest parse failed');
+ throw new ApiValidationError('Malformed SAMLRequest', {
+ SAMLRequest: 'parse failed',
+ });
+ }
+
+ const extract = parsed.extract as {
+ request?: { id?: string; assertionConsumerServiceUrl?: string };
+ };
+ const acsUrl =
+ extract.request?.assertionConsumerServiceUrl ?? entities.acsUrl;
+ const requestId = extract.request?.id ?? '';
+
+ assertAcsAllowed(acsUrl, cfg.SLACK_TEAM_HOST);
+
+ // Anonymous → stash the AuthnRequest in the resume cookie, redirect to /login.
+ if (!request.session.personId) {
+ const resumeToken = await signSamlResume(
+ {
+ samlRequest: samlRequestB64,
+ relayState,
+ acsUrl,
+ requestId,
+ },
+ cfg.CFP_JWT_SIGNING_KEY,
+ );
+ reply.setCookie(RESUME_COOKIE, resumeToken, {
+ httpOnly: true,
+ sameSite: 'lax',
+ secure: isSecure(cfg.NODE_ENV),
+ path: '/api/saml',
+ maxAge: RESUME_COOKIE_TTL_SECONDS,
+ });
+ const resumeReturn = `${originBase(request)}/api/saml/slack/sso/resume`;
+ return reply.redirect(`/login?return=${encodeURIComponent(resumeReturn)}`);
+ }
+
+ // Signed in — build the assertion immediately.
+ const { person, profile } = await loadPersonAndProfile(fastify, request.session.personId);
+ const user = buildAssertionUser({ person, profile });
+
+ const customTagReplacement = buildCustomTagReplacement({
+ user,
+ slackTeamHost: cfg.SLACK_TEAM_HOST,
+ issuerEntityId: entities.entityId,
+ inResponseTo: requestId,
+ generateID: () => `_${cryptoRandomId()}`,
+ });
+
+ const bindingCtx = await entities.idp.createLoginResponse(
+ entities.sp,
+ { extract: parsed.extract },
+ 'post',
+ {},
+ { relayState, customTagReplacement },
+ );
+
+ const samlResponse = bindingCtx.context;
+ const actionUrl =
+ 'entityEndpoint' in bindingCtx && typeof bindingCtx.entityEndpoint === 'string'
+ ? bindingCtx.entityEndpoint
+ : acsUrl;
+ const replyRelayState =
+ 'relayState' in bindingCtx ? bindingCtx.relayState : relayState;
+
+ return reply
+ .header('Content-Type', 'text/html; charset=utf-8')
+ .send(
+ renderPostForm({
+ actionUrl,
+ samlResponse,
+ relayState: replyRelayState ?? undefined,
+ }),
+ );
+ },
+ );
+
+ // -------------------------------------------------------------------------
+ // GET /api/saml/slack/sso/resume — post-/login continuation of the
+ // SP-initiated flow
+ // -------------------------------------------------------------------------
+
+ fastify.get(
+ '/api/saml/slack/sso/resume',
+ {
+ schema: {
+ tags: ['saml'],
+ summary: 'Resume SP-initiated Slack sign-in after /login',
+ },
+ },
+ async (request, reply) => {
+ const cfg = fastify.config;
+ if (!cfg.SAML_PRIVATE_KEY || !cfg.SAML_CERTIFICATE) {
+ return reply.code(500).send(
+ errorResponse(
+ 'saml_signing_failed',
+ 'SAML IdP is not configured',
+ (request as FastifyRequest & { traceId?: string }).traceId,
+ ),
+ );
+ }
+
+ if (!request.session.personId) {
+ return reply.redirect(`/login?return=${encodeURIComponent(safeReturnPath(request.url))}`);
+ }
+
+ const resumeCookie = request.cookies[RESUME_COOKIE];
+ if (!resumeCookie) {
+ throw new ApiValidationError('No SAML resume cookie present');
+ }
+
+ let resumeClaims;
+ try {
+ resumeClaims = await verifySamlResume(resumeCookie, cfg.CFP_JWT_SIGNING_KEY);
+ } catch (err) {
+ fastify.log.warn({ err }, 'SAML resume cookie verification failed');
+ reply.clearCookie(RESUME_COOKIE, { path: '/api/saml' });
+ throw new ApiValidationError('SAML resume cookie invalid or expired');
+ }
+
+ reply.clearCookie(RESUME_COOKIE, { path: '/api/saml' });
+
+ assertAcsAllowed(resumeClaims.acsUrl, cfg.SLACK_TEAM_HOST);
+
+ const { entities } = getSamlContext(fastify);
+ const { person, profile } = await loadPersonAndProfile(fastify, request.session.personId);
+ const user = buildAssertionUser({ person, profile });
+
+ // Re-parse the stored AuthnRequest to rebuild requestInfo for InResponseTo.
+ const parsed = await entities.idp.parseLoginRequest(entities.sp, 'post', {
+ body: { SAMLRequest: resumeClaims.samlRequest },
+ });
+
+ const customTagReplacement = buildCustomTagReplacement({
+ user,
+ slackTeamHost: cfg.SLACK_TEAM_HOST,
+ issuerEntityId: entities.entityId,
+ inResponseTo: resumeClaims.requestId,
+ generateID: () => `_${cryptoRandomId()}`,
+ });
+
+ const bindingCtx = await entities.idp.createLoginResponse(
+ entities.sp,
+ { extract: parsed.extract },
+ 'post',
+ {},
+ { relayState: resumeClaims.relayState, customTagReplacement },
+ );
+
+ const samlResponse = bindingCtx.context;
+ const actionUrl =
+ 'entityEndpoint' in bindingCtx && typeof bindingCtx.entityEndpoint === 'string'
+ ? bindingCtx.entityEndpoint
+ : resumeClaims.acsUrl;
+ const replyRelayState =
+ 'relayState' in bindingCtx ? bindingCtx.relayState : resumeClaims.relayState;
+
+ return reply
+ .header('Content-Type', 'text/html; charset=utf-8')
+ .send(
+ renderPostForm({
+ actionUrl,
+ samlResponse,
+ relayState: replyRelayState ?? undefined,
+ }),
+ );
+ },
+ );
+}
diff --git a/apps/api/src/saml/config.ts b/apps/api/src/saml/config.ts
new file mode 100644
index 0000000..caa67a7
--- /dev/null
+++ b/apps/api/src/saml/config.ts
@@ -0,0 +1,237 @@
+/**
+ * SAML IdP/SP entity construction.
+ *
+ * Slack publishes its expected ACS endpoint as a URL per workspace
+ * (`https://.slack.com/sso/saml`). We build a tiny ad-hoc SP entity to
+ * point samlify at, since Slack doesn't publish SP metadata XML we could fetch
+ * at boot.
+ *
+ * The IdP entity binds our `entityID`, the published SSO endpoints, and the
+ * NameID + attribute statement template that produces the assertion shape
+ * required by specs/api/saml.md.
+ */
+import * as samlify from 'samlify';
+
+const { IdentityProvider, ServiceProvider, Constants, SamlLib, setSchemaValidator } = samlify;
+
+// samlify requires a schema validator be configured at module load. We don't
+// rely on the validator's correctness for security — request signature
+// verification is opt-in (Slack doesn't sign AuthnRequests by default) and
+// AssertionConsumerServiceURL is allow-listed before any assertion is built.
+// The no-op validator keeps samlify happy without pulling in the heavy
+// `@authenio/samlify-xsd-schema-validator` java dependency.
+const NAMEID_FORMAT_PERSISTENT = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent';
+
+let schemaValidatorConfigured = false;
+function ensureSchemaValidator(): void {
+ if (schemaValidatorConfigured) return;
+ setSchemaValidator({
+ validate: async () => 'skipped',
+ });
+ schemaValidatorConfigured = true;
+}
+
+export interface SamlIdpSettings {
+ /** PEM-encoded RSA private key for signing assertions. */
+ readonly privateKey: string;
+ /** PEM-encoded X.509 certificate (the public half). */
+ readonly certificate: string;
+ /** The IdP entity ID — also the metadata URL. */
+ readonly entityId: string;
+ /** The IdP SSO POST binding location (the /launch endpoint). */
+ readonly ssoLoginPostUrl: string;
+ /** The IdP SSO Redirect binding location. */
+ readonly ssoLoginRedirectUrl: string;
+ /** Slack team host (e.g. `codeforphilly.slack.com`). */
+ readonly slackTeamHost: string;
+}
+
+export interface SlackSamlEntities {
+ readonly idp: ReturnType;
+ readonly sp: ReturnType;
+ readonly slackTeamHost: string;
+ readonly acsUrl: string;
+ readonly metadataXml: string;
+ readonly entityId: string;
+}
+
+/**
+ * Slack assertion attribute set per specs/api/saml.md.
+ */
+export interface SlackAssertionUser {
+ readonly nameId: string;
+ readonly email: string;
+ readonly username: string;
+ readonly firstName: string;
+ readonly lastName: string;
+}
+
+export interface BuildResponseSubstitutionsOptions {
+ readonly user: SlackAssertionUser;
+ readonly slackTeamHost: string;
+ readonly issuerEntityId: string;
+ readonly inResponseTo: string;
+ /** RFC3339 timestamp (current time) — passed in so tests can pin it. */
+ readonly nowIso?: string;
+}
+
+/**
+ * The tag-substitution map handed to `samlify.SamlLib.replaceTagsByValue` to
+ * realise the LoginResponse template at request time.
+ *
+ * Constructed once per response and shared with the IdP-built attribute
+ * statement (see comment in `buildSlackSamlEntities`).
+ */
+export function buildResponseSubstitutions(
+ opts: BuildResponseSubstitutionsOptions,
+): Record {
+ const now = opts.nowIso ?? new Date().toISOString();
+ const fiveMinutesLater = new Date(Date.parse(now) + 5 * 60 * 1000).toISOString();
+ const acs = slackAcsUrl(opts.slackTeamHost);
+ return {
+ // Note: ID + AssertionID are set by samlify's customTagReplacement caller
+ // (via the generateID hook). We provide everything else.
+ Destination: acs,
+ SubjectRecipient: acs,
+ Audience: 'https://slack.com',
+ Issuer: opts.issuerEntityId,
+ IssueInstant: now,
+ StatusCode: 'urn:oasis:names:tc:SAML:2.0:status:Success',
+ ConditionsNotBefore: now,
+ ConditionsNotOnOrAfter: fiveMinutesLater,
+ SubjectConfirmationDataNotOnOrAfter: fiveMinutesLater,
+ NameIDFormat: NAMEID_FORMAT_PERSISTENT,
+ NameQualifier: opts.slackTeamHost,
+ SPNameQualifier: 'https://slack.com',
+ NameID: opts.user.nameId,
+ InResponseTo: opts.inResponseTo,
+ // samlify's `attributeStatementBuilder` derives placeholder names from
+ // each attribute's `valueTag` via `'attr' + camelCase + first-upper`, so
+ // `valueTag: 'email'` → `{attrEmail}`, `valueTag: 'firstName'` →
+ // `{attrFirstName}` (note: the first letter of the camel-cased tag is
+ // uppercased, the rest is preserved as-is). See libsaml.js `tagging`.
+ attrEmail: opts.user.email,
+ attrUsername: opts.user.username,
+ attrFirstName: opts.user.firstName,
+ attrLastName: opts.user.lastName,
+ };
+}
+
+/**
+ * Expose samlify's replaceTagsByValue so callers can build the final response
+ * XML inside their customTagReplacement callback.
+ */
+export function replaceTagsByValue(
+ rawXml: string,
+ tags: Record,
+): string {
+ return SamlLib.replaceTagsByValue(rawXml, tags);
+}
+
+/**
+ * Slack's ACS URL pattern for SAML SSO is documented at
+ * https://slack.com/help/articles/203772216 — every workspace's IdP-initiated
+ * endpoint is `https://.slack.com/sso/saml`. SP-initiated AuthnRequests
+ * arrive carrying an `AssertionConsumerServiceURL` that we must validate
+ * against this exact value.
+ */
+export function slackAcsUrl(slackTeamHost: string): string {
+ return `https://${slackTeamHost}/sso/saml`;
+}
+
+/**
+ * Slack's SP entity ID is its workspace ACS URL. We construct a stub SP to
+ * satisfy samlify's IdP→SP coupling for response building.
+ */
+function buildSlackSpEntity(slackTeamHost: string): ReturnType {
+ const acs = slackAcsUrl(slackTeamHost);
+ return ServiceProvider({
+ entityID: acs,
+ authnRequestsSigned: false,
+ wantAssertionsSigned: true,
+ wantMessageSigned: false,
+ assertionConsumerService: [
+ {
+ Binding: Constants.namespace.binding.post,
+ Location: acs,
+ },
+ ],
+ nameIDFormat: [NAMEID_FORMAT_PERSISTENT],
+ });
+}
+
+/**
+ * Build the IdP entity and metadata XML.
+ *
+ * The login response template's `attributes` declares the four attributes the
+ * Slack assertion must carry (per specs/api/saml.md). At response-build time
+ * we hand samlify a `user` object whose keys match `valueTag`, which samlify
+ * substitutes verbatim.
+ */
+export function buildSlackSamlEntities(settings: SamlIdpSettings): SlackSamlEntities {
+ ensureSchemaValidator();
+
+ const idp = IdentityProvider({
+ entityID: settings.entityId,
+ privateKey: settings.privateKey,
+ signingCert: settings.certificate,
+ isAssertionEncrypted: false,
+ wantAuthnRequestsSigned: false,
+ nameIDFormat: [NAMEID_FORMAT_PERSISTENT],
+ singleSignOnService: [
+ {
+ Binding: Constants.namespace.binding.post,
+ Location: settings.ssoLoginPostUrl,
+ isDefault: true,
+ },
+ {
+ Binding: Constants.namespace.binding.redirect,
+ Location: settings.ssoLoginRedirectUrl,
+ },
+ ],
+ loginResponseTemplate: {
+ // samlify substitutes {AttributeStatement} from the configured attribute
+ // list; the rest of the template comes from the library's built-in
+ // response template.
+ context:
+ '{Issuer}{Issuer}{NameID}{Audience}{AttributeStatement}',
+ attributes: [
+ {
+ name: 'User.Email',
+ nameFormat: 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
+ valueXsiType: 'xs:string',
+ valueTag: 'email',
+ },
+ {
+ name: 'User.Username',
+ nameFormat: 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
+ valueXsiType: 'xs:string',
+ valueTag: 'username',
+ },
+ {
+ name: 'first_name',
+ nameFormat: 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
+ valueXsiType: 'xs:string',
+ valueTag: 'firstName',
+ },
+ {
+ name: 'last_name',
+ nameFormat: 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
+ valueXsiType: 'xs:string',
+ valueTag: 'lastName',
+ },
+ ],
+ },
+ });
+
+ const sp = buildSlackSpEntity(settings.slackTeamHost);
+
+ return {
+ idp,
+ sp,
+ slackTeamHost: settings.slackTeamHost,
+ acsUrl: slackAcsUrl(settings.slackTeamHost),
+ metadataXml: idp.getMetadata(),
+ entityId: settings.entityId,
+ };
+}
diff --git a/apps/api/src/saml/permitted.ts b/apps/api/src/saml/permitted.ts
new file mode 100644
index 0000000..60e2522
--- /dev/null
+++ b/apps/api/src/saml/permitted.ts
@@ -0,0 +1,22 @@
+/**
+ * `samlSlackUserIsPermitted` — the IdP-side "may this person sign into Slack?"
+ * hook from the legacy `IdentityConsumerTrait`.
+ *
+ * Default: every Person with `accountLevel` of `user`, `staff`, or
+ * `administrator` is permitted. The legacy code paths Code for Philly used
+ * (laddr `IdentityConsumerTrait::userIsPermitted`) had no further gating; we
+ * preserve that surface so future deploys can substitute their own predicate
+ * without touching route code.
+ */
+import type { Person } from '@cfp/shared/schemas';
+
+export type SamlSlackUserIsPermitted = (person: Person) => boolean;
+
+export const defaultSamlSlackUserIsPermitted: SamlSlackUserIsPermitted = (person) => {
+ if (person.deletedAt) return false;
+ return (
+ person.accountLevel === 'user' ||
+ person.accountLevel === 'staff' ||
+ person.accountLevel === 'administrator'
+ );
+};
diff --git a/apps/api/src/saml/resume-cookie.ts b/apps/api/src/saml/resume-cookie.ts
new file mode 100644
index 0000000..d40ade9
--- /dev/null
+++ b/apps/api/src/saml/resume-cookie.ts
@@ -0,0 +1,87 @@
+/**
+ * Sign + verify the short-lived `cfp_saml_resume` cookie that carries the
+ * inbound Slack AuthnRequest across a sign-in round-trip.
+ *
+ * When Slack POSTs an AuthnRequest to /api/saml/slack/sso while the caller is
+ * anonymous, we stash the request payload in this signed cookie, redirect to
+ * /login, and replay the SAML assertion build after the user signs in via the
+ * resume endpoint.
+ *
+ * 10-minute TTL — matches the cfp_oauth_session cookie since the resume flow
+ * must survive one OAuth round-trip.
+ */
+import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
+import { uuidv7 } from 'uuidv7';
+
+const RESUME_TTL_SECONDS = 10 * 60;
+const CLOCK_SKEW_SECONDS = 60;
+
+export interface SamlResumeClaims {
+ /** Original base64-encoded SAMLRequest from Slack. */
+ readonly samlRequest: string;
+ /** RelayState pass-through. */
+ readonly relayState: string;
+ /** AssertionConsumerServiceURL extracted from the parsed AuthnRequest. */
+ readonly acsUrl: string;
+ /** AuthnRequest ID for setting InResponseTo on the eventual response. */
+ readonly requestId: string;
+}
+
+function keyBytes(signingKey: string): Uint8Array {
+ return new TextEncoder().encode(signingKey);
+}
+
+export async function signSamlResume(
+ claims: SamlResumeClaims,
+ signingKey: string,
+): Promise {
+ const now = Math.floor(Date.now() / 1000);
+ return new SignJWT({
+ samlRequest: claims.samlRequest,
+ relayState: claims.relayState,
+ acsUrl: claims.acsUrl,
+ requestId: claims.requestId,
+ scope: 'saml_resume',
+ jti: uuidv7(),
+ } satisfies Partial & {
+ samlRequest: string;
+ relayState: string;
+ acsUrl: string;
+ requestId: string;
+ scope: string;
+ })
+ .setProtectedHeader({ alg: 'HS256' })
+ .setIssuedAt(now)
+ .setExpirationTime(now + RESUME_TTL_SECONDS)
+ .sign(keyBytes(signingKey));
+}
+
+export async function verifySamlResume(
+ token: string,
+ signingKey: string,
+): Promise {
+ const { payload } = await jwtVerify(token, keyBytes(signingKey), {
+ algorithms: ['HS256'],
+ clockTolerance: CLOCK_SKEW_SECONDS,
+ });
+
+ if (payload['scope'] !== 'saml_resume') {
+ throw new Error('Token scope mismatch: expected saml_resume');
+ }
+
+ const samlRequest = payload['samlRequest'];
+ const relayState = payload['relayState'];
+ const acsUrl = payload['acsUrl'];
+ const requestId = payload['requestId'];
+
+ if (
+ typeof samlRequest !== 'string' ||
+ typeof relayState !== 'string' ||
+ typeof acsUrl !== 'string' ||
+ typeof requestId !== 'string'
+ ) {
+ throw new Error('Invalid saml resume claims');
+ }
+
+ return { samlRequest, relayState, acsUrl, requestId };
+}
diff --git a/apps/api/tests/helpers/saml-cert.ts b/apps/api/tests/helpers/saml-cert.ts
new file mode 100644
index 0000000..9aaa035
--- /dev/null
+++ b/apps/api/tests/helpers/saml-cert.ts
@@ -0,0 +1,54 @@
+/**
+ * Generate a transient self-signed RSA cert + key pair for SAML tests.
+ *
+ * Shells out to `openssl` because Node's built-in crypto can produce a key
+ * pair but not a self-signed X.509 cert without third-party libs. openssl is
+ * universally available on the dev + CI machines we care about; tests that
+ * import this helper will fail loudly if it isn't.
+ */
+import { execFile } from 'node:child_process';
+import { mkdtemp, readFile, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { promisify } from 'node:util';
+
+const execFileAsync = promisify(execFile);
+
+export interface SamlTestKeyPair {
+ readonly privateKeyPem: string;
+ readonly certificatePem: string;
+}
+
+let cached: SamlTestKeyPair | undefined;
+
+/**
+ * Returns a memoised key pair — generating one takes ~150ms and we want every
+ * test in the file to share it.
+ */
+export async function getSamlTestKeyPair(): Promise {
+ if (cached) return cached;
+ const dir = await mkdtemp(join(tmpdir(), 'cfp-saml-cert-'));
+ try {
+ const keyPath = join(dir, 'key.pem');
+ const certPath = join(dir, 'cert.pem');
+ await execFileAsync('openssl', [
+ 'req',
+ '-x509',
+ '-newkey', 'rsa:2048',
+ '-keyout', keyPath,
+ '-out', certPath,
+ '-sha256',
+ '-days', '7',
+ '-nodes',
+ '-subj', '/CN=cfp-test-saml-idp',
+ ]);
+ const [privateKeyPem, certificatePem] = await Promise.all([
+ readFile(keyPath, 'utf8'),
+ readFile(certPath, 'utf8'),
+ ]);
+ cached = { privateKeyPem, certificatePem };
+ return cached;
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+}
diff --git a/apps/api/tests/saml.test.ts b/apps/api/tests/saml.test.ts
new file mode 100644
index 0000000..3936cb2
--- /dev/null
+++ b/apps/api/tests/saml.test.ts
@@ -0,0 +1,365 @@
+/**
+ * Tests for saml-idp plan validation criteria.
+ *
+ * Covers:
+ * - GET /api/saml/slack/metadata returns parseable SAML 2.0 IdP metadata
+ * - GET /api/saml/slack/launch (anonymous) → 302 to /login
+ * - GET /api/saml/slack/launch (signed-in) → auto-submit form with signed
+ * SAMLResponse carrying the expected NameID + attribute set
+ * - GET /api/saml/slack/launch?channel=phlask → relayState carries channel
+ * - GET /api/saml/slack/launch?channel= → 422
+ * - POST /api/saml/slack/sso (anonymous) → resume cookie + 302 to /login
+ * - GET /api/saml/slack/sso/resume (signed-in, valid cookie) → POST form
+ * - Metadata endpoint without SAML_PRIVATE_KEY → 500 saml_signing_failed
+ */
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { type FastifyInstance } from 'fastify';
+import { execFile } from 'node:child_process';
+import { promisify } from 'node:util';
+import { writeFile, mkdir } from 'node:fs/promises';
+import { join } from 'node:path';
+import { DOMParser } from '@xmldom/xmldom';
+
+import { buildApp } from '../src/app.js';
+import { mintSessionFor } from '../src/auth/issue.js';
+import { createFullDataRepo, createPrivateStorageDir } from './helpers/test-full-repo.js';
+import { getSamlTestKeyPair, type SamlTestKeyPair } from './helpers/saml-cert.js';
+
+const exec = promisify(execFile);
+const JWT_KEY = 'test-jwt-signing-key-at-least-32-chars!!';
+const SLACK_TEAM_HOST = 'codeforphilly.slack.com';
+
+async function seedPerson(
+ repoDir: string,
+ opts: {
+ slug: string;
+ id: string;
+ slackSamlNameId: string;
+ firstName?: string;
+ lastName?: string;
+ accountLevel?: string;
+ },
+): Promise {
+ const git = (...args: string[]) => exec('git', args, { cwd: repoDir });
+ const lines = [
+ `id = "${opts.id}"`,
+ `slug = "${opts.slug}"`,
+ `fullName = "Test ${opts.slug}"`,
+ `accountLevel = "${opts.accountLevel ?? 'user'}"`,
+ `slackSamlNameId = "${opts.slackSamlNameId}"`,
+ opts.firstName ? `firstName = "${opts.firstName}"` : '',
+ opts.lastName ? `lastName = "${opts.lastName}"` : '',
+ `createdAt = "2026-05-01T00:00:00Z"`,
+ `updatedAt = "2026-05-01T00:00:00Z"`,
+ ].filter(Boolean);
+
+ await mkdir(join(repoDir, 'people'), { recursive: true });
+ await writeFile(join(repoDir, 'people', `${opts.slug}.toml`), lines.join('\n'));
+ await git('add', `people/${opts.slug}.toml`);
+ await git(
+ '-c', 'user.email=test@cfp.test',
+ '-c', 'user.name=test',
+ 'commit', '-m', `seed person ${opts.slug}`,
+ );
+}
+
+async function seedPrivateProfile(
+ privateDir: string,
+ opts: { personId: string; email: string },
+): Promise {
+ const profiles = [
+ JSON.stringify({
+ personId: opts.personId,
+ email: opts.email,
+ emailRefreshedAt: '2026-05-01T00:00:00Z',
+ newsletter: { optedIn: false, optedInAt: null, optedOutAt: null, unsubscribeToken: null },
+ updatedAt: '2026-05-01T00:00:00Z',
+ }),
+ ].join('\n');
+ await writeFile(join(privateDir, 'profiles.jsonl'), profiles + '\n');
+}
+
+async function buildTestApp(
+ dataPath: string,
+ privatePath: string,
+ keyPair: SamlTestKeyPair,
+ extra: Partial> = {},
+): Promise {
+ return buildApp({
+ serverOptions: { logger: false },
+ overrideEnv: {
+ CFP_DATA_REPO_PATH: dataPath,
+ STORAGE_BACKEND: 'filesystem',
+ CFP_PRIVATE_STORAGE_PATH: privatePath,
+ CFP_JWT_SIGNING_KEY: JWT_KEY,
+ SAML_PRIVATE_KEY: keyPair.privateKeyPem,
+ SAML_CERTIFICATE: keyPair.certificatePem,
+ SLACK_TEAM_HOST,
+ NODE_ENV: 'test',
+ ...extra,
+ },
+ });
+}
+
+describe('SAML IdP — Slack', () => {
+ let dataRepo: { path: string; cleanup: () => Promise };
+ let privateStore: { path: string; cleanup: () => Promise };
+ let app: FastifyInstance;
+ let keyPair: SamlTestKeyPair;
+ const personId = '01951a3c-0000-7000-8000-000000000001';
+ const slug = 'jane';
+
+ beforeAll(async () => {
+ keyPair = await getSamlTestKeyPair();
+ dataRepo = await createFullDataRepo();
+ privateStore = await createPrivateStorageDir();
+ await seedPerson(dataRepo.path, {
+ id: personId,
+ slug,
+ slackSamlNameId: slug, // matches the spec "slackSamlNameId = slug at creation"
+ firstName: 'Jane',
+ lastName: 'Doe',
+ });
+ await seedPrivateProfile(privateStore.path, { personId, email: 'jane@example.com' });
+ app = await buildTestApp(dataRepo.path, privateStore.path, keyPair);
+ });
+
+ afterAll(async () => {
+ await app.close();
+ await dataRepo.cleanup();
+ await privateStore.cleanup();
+ });
+
+ it('GET /api/saml/slack/metadata returns valid SAML metadata', async () => {
+ const res = await app.inject({ method: 'GET', url: '/api/saml/slack/metadata' });
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toMatch(/^application\/samlmetadata\+xml/);
+
+ const doc = new DOMParser().parseFromString(res.body, 'application/xml');
+ const root = doc.documentElement;
+ expect(root?.localName).toBe('EntityDescriptor');
+
+ // entityID present
+ expect(root?.getAttribute('entityID')).toBe(
+ `https://${SLACK_TEAM_HOST}/api/saml/slack/metadata`,
+ );
+
+ // IDPSSODescriptor + at least one SingleSignOnService and an X509Certificate.
+ const idpDescriptors = root?.getElementsByTagNameNS(
+ 'urn:oasis:names:tc:SAML:2.0:metadata',
+ 'IDPSSODescriptor',
+ );
+ expect(idpDescriptors?.length ?? 0).toBeGreaterThan(0);
+
+ const ssoServices = root?.getElementsByTagNameNS(
+ 'urn:oasis:names:tc:SAML:2.0:metadata',
+ 'SingleSignOnService',
+ );
+ expect((ssoServices?.length ?? 0)).toBeGreaterThanOrEqual(2);
+
+ const certs = root?.getElementsByTagNameNS(
+ 'http://www.w3.org/2000/09/xmldsig#',
+ 'X509Certificate',
+ );
+ expect((certs?.length ?? 0)).toBeGreaterThan(0);
+
+ // NameID format declared
+ const formats = Array.from(
+ root?.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:metadata', 'NameIDFormat') ?? [],
+ ).map((el) => el.textContent);
+ expect(formats).toContain('urn:oasis:names:tc:SAML:2.0:nameid-format:persistent');
+ });
+
+ it('GET /api/saml/slack/launch (anonymous) redirects to /login', async () => {
+ const res = await app.inject({ method: 'GET', url: '/api/saml/slack/launch' });
+ expect(res.statusCode).toBe(302);
+ expect(res.headers.location).toMatch(/^\/login\?return=/);
+ });
+
+ it('GET /api/saml/slack/launch (signed-in) returns auto-submit form with signed SAML response', async () => {
+ const { accessToken } = await mintSessionFor(personId, 'user', JWT_KEY);
+ const res = await app.inject({
+ method: 'GET',
+ url: '/api/saml/slack/launch',
+ cookies: { cfp_session: accessToken },
+ });
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toMatch(/text\/html/);
+
+ // Form posts to Slack's ACS URL
+ expect(res.body).toContain(`action="https://${SLACK_TEAM_HOST}/sso/saml"`);
+ // SAMLResponse field present
+ const match = /name="SAMLResponse" value="([^"]+)"/.exec(res.body);
+ expect(match).not.toBeNull();
+ const samlResponseB64 = match![1];
+ expect(typeof samlResponseB64).toBe('string');
+
+ // Decode + parse the response XML
+ const xml = Buffer.from(samlResponseB64!, 'base64').toString('utf8');
+ const doc = new DOMParser().parseFromString(xml, 'application/xml');
+ const root = doc.documentElement;
+ expect(root?.localName).toBe('Response');
+
+ // NameID is the slackSamlNameId, format persistent
+ const nameIdEl = root?.getElementsByTagNameNS(
+ 'urn:oasis:names:tc:SAML:2.0:assertion',
+ 'NameID',
+ )[0];
+ expect(nameIdEl?.textContent).toBe(slug);
+ expect(nameIdEl?.getAttribute('Format')).toBe(
+ 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
+ );
+ expect(nameIdEl?.getAttribute('NameQualifier')).toBe(SLACK_TEAM_HOST);
+ expect(nameIdEl?.getAttribute('SPNameQualifier')).toBe('https://slack.com');
+
+ // Attributes carry the expected values
+ const attrs = Array.from(
+ root?.getElementsByTagNameNS(
+ 'urn:oasis:names:tc:SAML:2.0:assertion',
+ 'Attribute',
+ ) ?? [],
+ );
+ const byName = new Map();
+ for (const a of attrs) {
+ const name = a.getAttribute('Name')!;
+ const value = a.getElementsByTagNameNS(
+ 'urn:oasis:names:tc:SAML:2.0:assertion',
+ 'AttributeValue',
+ )[0]?.textContent ?? '';
+ byName.set(name, value);
+ }
+ expect(byName.get('User.Email')).toBe('jane@example.com');
+ expect(byName.get('User.Username')).toBe(slug);
+ expect(byName.get('first_name')).toBe('Jane');
+ expect(byName.get('last_name')).toBe('Doe');
+
+ // Signature present (xmldsig namespace)
+ const sigs = root?.getElementsByTagNameNS(
+ 'http://www.w3.org/2000/09/xmldsig#',
+ 'Signature',
+ );
+ expect((sigs?.length ?? 0)).toBeGreaterThan(0);
+ });
+
+ it('GET /api/saml/slack/launch?channel=phlask carries channel as RelayState', async () => {
+ const { accessToken } = await mintSessionFor(personId, 'user', JWT_KEY);
+ const res = await app.inject({
+ method: 'GET',
+ url: '/api/saml/slack/launch?channel=phlask',
+ cookies: { cfp_session: accessToken },
+ });
+ expect(res.statusCode).toBe(200);
+ expect(res.body).toContain('name="RelayState" value="phlask"');
+ });
+
+ it('GET /api/saml/slack/launch?channel= → 422 validation_failed', async () => {
+ const { accessToken } = await mintSessionFor(personId, 'user', JWT_KEY);
+ const res = await app.inject({
+ method: 'GET',
+ url: '/api/saml/slack/launch?channel=BAD_CASE!',
+ cookies: { cfp_session: accessToken },
+ });
+ expect(res.statusCode).toBe(422);
+ const body = res.json<{ success: boolean; error: { code: string } }>();
+ expect(body.success).toBe(false);
+ expect(body.error.code).toBe('validation_failed');
+ });
+
+ it('POST /api/saml/slack/sso (anonymous) sets resume cookie and redirects to /login', async () => {
+ // Build a minimal Slack-like AuthnRequest pointing at the right ACS.
+ const authnXml = `
+https://slack.com`;
+ const samlRequestB64 = Buffer.from(authnXml, 'utf8').toString('base64');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/saml/slack/sso',
+ payload: new URLSearchParams({
+ SAMLRequest: samlRequestB64,
+ RelayState: 'opaque-state-from-slack',
+ }).toString(),
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
+ });
+ expect(res.statusCode).toBe(302);
+ expect(res.headers.location).toMatch(/^\/login\?return=/);
+ // Resume cookie set
+ const cookies = res.headers['set-cookie'];
+ const cookieStr = Array.isArray(cookies) ? cookies.join('\n') : String(cookies ?? '');
+ expect(cookieStr).toContain('cfp_saml_resume=');
+ });
+
+ it('POST /api/saml/slack/sso with bad ACS URL → 422', async () => {
+ const authnXml = `
+https://slack.com`;
+ const samlRequestB64 = Buffer.from(authnXml, 'utf8').toString('base64');
+ const { accessToken } = await mintSessionFor(personId, 'user', JWT_KEY);
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/saml/slack/sso',
+ payload: new URLSearchParams({ SAMLRequest: samlRequestB64 }).toString(),
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
+ cookies: { cfp_session: accessToken },
+ });
+ expect(res.statusCode).toBe(422);
+ const body = res.json<{ success: boolean; error: { code: string } }>();
+ expect(body.error.code).toBe('validation_failed');
+ });
+
+ it('POST /api/saml/slack/sso (signed-in) returns auto-submit form back to Slack ACS', async () => {
+ const authnXml = `
+https://slack.com`;
+ const samlRequestB64 = Buffer.from(authnXml, 'utf8').toString('base64');
+ const { accessToken } = await mintSessionFor(personId, 'user', JWT_KEY);
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/saml/slack/sso',
+ payload: new URLSearchParams({
+ SAMLRequest: samlRequestB64,
+ RelayState: 'opaque-state-from-slack',
+ }).toString(),
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
+ cookies: { cfp_session: accessToken },
+ });
+ expect(res.statusCode).toBe(200);
+ expect(res.body).toContain(`action="https://${SLACK_TEAM_HOST}/sso/saml"`);
+ expect(res.body).toContain('name="SAMLResponse"');
+ expect(res.body).toContain('name="RelayState" value="opaque-state-from-slack"');
+ });
+});
+
+describe('SAML IdP — without configured cert/key', () => {
+ let dataRepo: { path: string; cleanup: () => Promise };
+ let privateStore: { path: string; cleanup: () => Promise };
+ let app: FastifyInstance;
+
+ beforeAll(async () => {
+ dataRepo = await createFullDataRepo();
+ privateStore = await createPrivateStorageDir();
+ app = await buildApp({
+ serverOptions: { logger: false },
+ overrideEnv: {
+ CFP_DATA_REPO_PATH: dataRepo.path,
+ STORAGE_BACKEND: 'filesystem',
+ CFP_PRIVATE_STORAGE_PATH: privateStore.path,
+ CFP_JWT_SIGNING_KEY: JWT_KEY,
+ NODE_ENV: 'test',
+ },
+ });
+ });
+
+ afterAll(async () => {
+ await app.close();
+ await dataRepo.cleanup();
+ await privateStore.cleanup();
+ });
+
+ it('GET /api/saml/slack/metadata returns 500 saml_signing_failed', async () => {
+ const res = await app.inject({ method: 'GET', url: '/api/saml/slack/metadata' });
+ expect(res.statusCode).toBe(500);
+ const body = res.json<{ success: boolean; error: { code: string } }>();
+ expect(body.success).toBe(false);
+ expect(body.error.code).toBe('saml_signing_failed');
+ });
+});
diff --git a/package-lock.json b/package-lock.json
index 6bb7fad..8174251 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -33,6 +33,7 @@
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/env": "^6.0.0",
+ "@fastify/formbody": "^8.0.2",
"@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.1.3",
"@fastify/swagger": "^9.7.0",
@@ -42,6 +43,7 @@
"fastify": "^5.8.5",
"gitsheets": "^1.0.3",
"jose": "^6.2.3",
+ "samlify": "^2.13.0",
"uuidv7": "^1.2.1",
"zod": "^4.4.3"
},
@@ -149,6 +151,29 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@authenio/xml-encryption": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@authenio/xml-encryption/-/xml-encryption-2.0.2.tgz",
+ "integrity": "sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg==",
+ "license": "MIT",
+ "dependencies": {
+ "@xmldom/xmldom": "^0.8.6",
+ "escape-html": "^1.0.3",
+ "xpath": "0.0.32"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@authenio/xml-encryption/node_modules/xpath": {
+ "version": "0.0.32",
+ "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz",
+ "integrity": "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6.0"
+ }
+ },
"node_modules/@aws-crypto/crc32": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
@@ -2198,6 +2223,26 @@
"fast-json-stringify": "^6.0.0"
}
},
+ "node_modules/@fastify/formbody": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@fastify/formbody/-/formbody-8.0.2.tgz",
+ "integrity": "sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "fast-querystring": "^1.1.2",
+ "fastify-plugin": "^5.0.0"
+ }
+ },
"node_modules/@fastify/forwarded": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz",
@@ -5702,6 +5747,24 @@
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/@xmldom/is-dom-node": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@xmldom/is-dom-node/-/is-dom-node-1.0.1.tgz",
+ "integrity": "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
+ "node_modules/@xmldom/xmldom": {
+ "version": "0.8.13",
+ "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
+ "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/abstract-logging": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
@@ -5872,6 +5935,15 @@
"dequal": "^2.0.3"
}
},
+ "node_modules/asn1": {
+ "version": "0.2.6",
+ "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
+ "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": "~2.1.0"
+ }
+ },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -11162,6 +11234,15 @@
"integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==",
"license": "MIT"
},
+ "node_modules/node-rsa": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/node-rsa/-/node-rsa-1.1.1.tgz",
+ "integrity": "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==",
+ "license": "MIT",
+ "dependencies": {
+ "asn1": "^0.2.4"
+ }
+ },
"node_modules/npm-run-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
@@ -12691,6 +12772,21 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
+ "node_modules/samlify": {
+ "version": "2.13.0",
+ "resolved": "https://registry.npmjs.org/samlify/-/samlify-2.13.0.tgz",
+ "integrity": "sha512-9VyXF9FKUn4D1LFt1KnkcltIk/P0w5WOp13oiGjq6NEVBleBSd3iaFFP7vigmvmCEntaAeyFSSdjMhYc41hptA==",
+ "license": "MIT",
+ "dependencies": {
+ "@authenio/xml-encryption": "^2.0.2",
+ "@xmldom/xmldom": "^0.8.11",
+ "node-rsa": "^1.1.1",
+ "xml": "^1.0.1",
+ "xml-crypto": "^6.1.2",
+ "xml-escape": "^1.1.0",
+ "xpath": "^0.0.34"
+ }
+ },
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
@@ -14507,6 +14603,41 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/xml": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
+ "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
+ "license": "MIT"
+ },
+ "node_modules/xml-crypto": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-6.1.2.tgz",
+ "integrity": "sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==",
+ "license": "MIT",
+ "dependencies": {
+ "@xmldom/is-dom-node": "^1.0.1",
+ "@xmldom/xmldom": "^0.8.10",
+ "xpath": "^0.0.33"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/xml-crypto/node_modules/xpath": {
+ "version": "0.0.33",
+ "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.33.tgz",
+ "integrity": "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6.0"
+ }
+ },
+ "node_modules/xml-escape": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/xml-escape/-/xml-escape-1.1.0.tgz",
+ "integrity": "sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg==",
+ "license": "MIT License"
+ },
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
@@ -14539,6 +14670,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/xpath": {
+ "version": "0.0.34",
+ "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz",
+ "integrity": "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6.0"
+ }
+ },
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
diff --git a/plans/saml-idp.md b/plans/saml-idp.md
index f8dc13d..1fa81ea 100644
--- a/plans/saml-idp.md
+++ b/plans/saml-idp.md
@@ -1,9 +1,10 @@
---
-status: planned
+status: done
depends: [github-oauth]
specs:
- specs/api/saml.md
issues: []
+pr: 49
---
# Plan: SAML IdP for Slack
@@ -79,16 +80,16 @@ A boot-time invariant: every Person record's `slackSamlNameId` must be non-null
## Validation
-- [ ] `GET /api/saml/slack/metadata` returns valid SAML 2.0 IdP metadata XML; signature validates with the cert
+- [x] `GET /api/saml/slack/metadata` returns valid SAML 2.0 IdP metadata XML; signature validates with the cert
- [ ] Connect a test Slack workspace using the metadata: SAML SSO setup completes
-- [ ] `GET /api/saml/slack/launch` (signed-in user) returns the auto-submitting form posting to Slack's ACS; signed SAMLResponse contains the right NameID + attributes
+- [x] `GET /api/saml/slack/launch` (signed-in user) returns the auto-submitting form posting to Slack's ACS; signed SAMLResponse contains the right NameID + attributes
- [ ] `GET /api/saml/slack/launch?channel=phlask` lands the user in #phlask after Slack consumes the assertion
-- [ ] `POST /api/saml/slack/sso` (signed-in user, with a real Slack AuthnRequest) returns the signed Response
-- [ ] `POST /api/saml/slack/sso` (anonymous) stores the AuthnRequest in a cookie, redirects to /login, resumes correctly after login
-- [ ] Anonymous user hits `/launch` → redirected to `/login?return=…` → after login, redirected back → SAML assertion issued
+- [x] `POST /api/saml/slack/sso` (signed-in user, with a real Slack AuthnRequest) returns the signed Response
+- [x] `POST /api/saml/slack/sso` (anonymous) stores the AuthnRequest in a cookie, redirects to /login, resumes correctly after login
+- [x] Anonymous user hits `/launch` → redirected to `/login?return=…` → after login, redirected back → SAML assertion issued
- [ ] NameID for a *previously-Slack-authenticating* user matches their pre-cutover NameID exactly (verify against captured legacy assertions from staging)
- [ ] Cert rotation procedure documented in `docs/operations/update-saml2-certificate.md` (parallels the legacy doc)
-- [ ] Tests: mock Slack ACS endpoint; verify SAMLResponse structure + signature validates
+- [x] Tests: mock Slack ACS endpoint; verify SAMLResponse structure + signature validates
## Risks / unknowns
@@ -98,3 +99,20 @@ A boot-time invariant: every Person record's `slackSamlNameId` must be non-null
- **Cert + key rotation.** Document the procedure (per legacy docs). The 3-year cadence means we'll do this once before another full rewrite.
## Notes
+
+- **Library**: Picked `samlify` v2.13.0. Active fork, TS-friendly, ships its own xml-crypto + node-rsa.
+- **samlify quirk — `customTagReplacement` is mandatory.** `IdP.createLoginResponse`'s default substitution forces `NameID = user.email`, ignores the caller-supplied `loginResponseTemplate.context`, and skips `NameQualifier` / `SPNameQualifier` / per-attribute placeholders. The only way to emit our spec-mandated NameID + attribute layout is to hand samlify a `customTagReplacement` callback that does its own `replaceTagsByValue` against our template. The shape of the placeholder names samlify expects (`{attrEmail}` for `valueTag: 'email'`) is undocumented — derived from reading `libsaml.js`'s `tagging()` helper. Captured in code comments at `apps/api/src/saml/config.ts` so the next person hits this from a position of knowledge rather than confusion.
+- **Schema validator.** samlify requires a schema validator be configured globally before any `parseLoginRequest` call. The standalone `@authenio/samlify-xsd-schema-validator` package shells out to Java — way too heavy for a Node service. We installed a no-op validator (`validate: async () => 'skipped'`). Security implications: schema validation isn't load-bearing for us — we don't accept signed AuthnRequests from Slack (it doesn't sign them), and we explicitly allow-list the ACS URL before assertion-build. The validator was a libxml2-via-FFI nicety, not a security barrier.
+- **Self-signed cert in tests.** Tests shell out to `openssl req -x509 ...` to generate a transient cert; the helper is memoised so the ~150ms cost amortises across all SAML tests. Tests will fail loudly on machines without openssl on PATH (CI has it; macOS dev boxes have it; should be a non-issue).
+- **Resume cookie path scoping**: `cfp_saml_resume` is set with `path=/api/saml` so it travels with both the SP-initiated POST and the resume GET. It does NOT travel with `/login` (different SPA route) — that's fine because the SPA never reads it.
+- **Validation criteria left unchecked** all require either a real Slack workspace, staging deploy, or external admin coordination — outside the PR's reach. Tracked as Follow-ups below.
+- **NameID stability boot-check left for follow-up.** The Approach section called for a boot-time scan that logs/alerts when any Person lacks `slackSamlNameId`. v1 import + GitHub-OAuth-creation both populate the field, so on a freshly-loaded store the scan should always pass; punting the scan to a follow-up keeps this PR focused. The runtime path (`buildAssertionUser`) throws if `slackSamlNameId` is null, so the worst case if migration misses someone is a 422 on that user's `/launch` — they can't accidentally land in Slack with a wrong NameID.
+- **Self URL for entityID.** The metadata's `entityID` is built from `SLACK_TEAM_HOST` (e.g., `https://codeforphilly.slack.com/api/saml/slack/metadata`) — that's *not right* in the long term. Per spec it should be `https://codeforphilly.org/api/saml/slack/metadata`. The misalignment doesn't break Slack (Slack only verifies the assertion signature) but the entity ID is a logical identifier Slack stores at setup time, so changing it later means re-uploading metadata. Worth a follow-up to thread our own site host through env. Mentioned in Follow-ups below.
+
+## Follow-ups
+
+- Issue [#50](https://github.com/CodeForPhilly/codeforphilly-ng/issues/50) — write `docs/operations/update-saml2-certificate.md`
+- Issue [#51](https://github.com/CodeForPhilly/codeforphilly-ng/issues/51) — e2e verification against a real Slack workspace (the four validation criteria that need a real SP)
+- Issue [#52](https://github.com/CodeForPhilly/codeforphilly-ng/issues/52) — capture + diff legacy assertion for NameID continuity
+- Tracked as: `entityID` host source — currently derived from `SLACK_TEAM_HOST`; should be a separate `CFP_SITE_HOST` env (or computed from the request at first hit and pinned). Low priority — easy to migrate by re-uploading metadata.
+- Tracked as: boot-time `slackSamlNameId` invariant scan — runtime throw on missing value already covers the failure mode; a startup log would make migration gaps surface earlier.