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 + + +
+${hiddenFields.join('\n')} + +
+`; +} + +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.