Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,16 @@ CFP_JWT_SIGNING_KEY=change-me-to-a-random-string-at-least-32-chars
# PEM-encoded certificate matching SAML_PRIVATE_KEY.
# SAML_CERTIFICATE=-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----

# Stable IdP entity ID — also the <Issuer> on every assertion. Slack stores
# this at setup, so it must NOT change when CFP_SITE_HOST flips at cutover.
# Leave unset unless registering a separate IdP with a different workspace.
# See specs/api/saml.md#idp-identity-and-hosts.
# SAML_ENTITY_ID=https://codeforphilly.org/api/saml/slack/metadata

# Slack workspace host. Drives the ACS URL, NameID NameQualifier, and the
# /chat + /launch redirects. Never used for our own entity ID or endpoints.
# SLACK_TEAM_HOST=codeforphilly.slack.com

# ---------------------------------------------------------------------------
# Static SPA serving (production only)
# ---------------------------------------------------------------------------
Expand Down
24 changes: 21 additions & 3 deletions apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
*/
import { z } from 'zod';

/**
* Default SAML IdP entity ID. Stable across hosts — see the SAML_ENTITY_ID
* field below and specs/api/saml.md#idp-identity-and-hosts.
*/
export const SAML_ENTITY_ID_DEFAULT = 'https://codeforphilly.org/api/saml/slack/metadata';

export const EnvSchema = z.object({
/** TCP port the Fastify server listens on. */
PORT: z.coerce.number().default(3001),
Expand Down Expand Up @@ -49,8 +55,17 @@ export const EnvSchema = z.object({
/** SAML IdP certificate (PEM) for the Slack SAML integration. */
SAML_CERTIFICATE: z.string().optional(),
/**
* Slack workspace host. Used as the SAML `NameQualifier` per
* specs/api/saml.md and shared with the `/chat` redirect handler.
* SAML IdP entity ID — the metadata `entityID` and the `<Issuer>` on every
* assertion. A stable logical identifier Slack stores at setup time, so it
* deliberately does NOT follow CFP_SITE_HOST: the pre-cutover
* `next.codeforphilly.org` deploy and the post-cutover `codeforphilly.org`
* deploy present the same issuer. Per specs/api/saml.md#idp-identity-and-hosts.
*/
SAML_ENTITY_ID: z.url().default(SAML_ENTITY_ID_DEFAULT),
/**
* Slack workspace host. Used for the SAML ACS URL and `NameQualifier` per
* specs/api/saml.md and shared with the `/chat` redirect handler. Never
* used for our own IdP entity ID or endpoint URLs.
*/
SLACK_TEAM_HOST: z.string().default('codeforphilly.slack.com'),
/**
Expand All @@ -64,7 +79,9 @@ export const EnvSchema = z.object({
* `next-v2.codeforphilly.org` in sandbox). Used by the server-side
* markdown renderer to distinguish internal from external links — anchors
* with a host different from this one get `target="_blank" rel="noopener
* nofollow"`. Per specs/behaviors/markdown-rendering.md.
* nofollow"`. Per specs/behaviors/markdown-rendering.md. Also the host the
* SAML IdP metadata advertises for its SSO endpoint Locations (per
* specs/api/saml.md#idp-identity-and-hosts).
*/
CFP_SITE_HOST: z.string().default('codeforphilly.org'),
/**
Expand Down Expand Up @@ -121,6 +138,7 @@ export const envJsonSchema = {
CFP_JWT_SIGNING_KEY: { type: 'string', minLength: 1 },
SAML_PRIVATE_KEY: { type: 'string' },
SAML_CERTIFICATE: { type: 'string' },
SAML_ENTITY_ID: { type: 'string', default: SAML_ENTITY_ID_DEFAULT },
SLACK_TEAM_HOST: { type: 'string', default: 'codeforphilly.slack.com' },
CFP_WEB_DIST_PATH: { type: 'string' },
CFP_SITE_HOST: { type: 'string', default: 'codeforphilly.org' },
Expand Down
19 changes: 11 additions & 8 deletions apps/api/src/routes/saml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,18 +128,21 @@ function getSamlContext(fastify: FastifyInstance): SamlContext {
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.
// Three distinct sources, per specs/api/saml.md#idp-identity-and-hosts:
// - entityId (metadata entityID + assertion Issuer) is the stable
// SAML_ENTITY_ID — it must NOT track the serving host, because Slack
// stored it at setup and the host flips at cutover;
// - the SSO endpoint Locations follow CFP_SITE_HOST so the metadata
// points Slack at whatever host this deployment answers on;
// - SLACK_TEAM_HOST is Slack's side only (ACS URL, NameQualifier).
const ssoUrl = `https://${cfg.CFP_SITE_HOST}/api/saml/slack/sso`;
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`,
entityId: cfg.SAML_ENTITY_ID,
ssoLoginPostUrl: ssoUrl,
ssoLoginRedirectUrl: ssoUrl,
slackTeamHost: cfg.SLACK_TEAM_HOST,
}),
};
Expand Down
16 changes: 12 additions & 4 deletions apps/api/src/saml/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,21 @@ export interface SamlIdpSettings {
readonly privateKey: string;
/** PEM-encoded X.509 certificate (the public half). */
readonly certificate: string;
/** The IdP entity ID — also the metadata URL. */
/**
* The IdP entity ID — becomes the metadata `entityID` AND the `<Issuer>`
* on every Response/Assertion (via `SlackSamlEntities.entityId` →
* `issuerEntityId`). A stable logical identifier (`SAML_ENTITY_ID`), not
* necessarily a URL that resolves on the serving host.
*/
readonly entityId: string;
/** The IdP SSO POST binding location (the /launch endpoint). */
/** The IdP SSO POST binding location — `https://<CFP_SITE_HOST>/api/saml/slack/sso`. */
readonly ssoLoginPostUrl: string;
/** The IdP SSO Redirect binding location. */
/** The IdP SSO Redirect binding location — same URL as the POST binding. */
readonly ssoLoginRedirectUrl: string;
/** Slack team host (e.g. `codeforphilly.slack.com`). */
/**
* Slack team host (e.g. `codeforphilly.slack.com`). Slack-side only: the
* ACS URL and the NameID `NameQualifier`. Never part of our own identity.
*/
readonly slackTeamHost: string;
}

Expand Down
115 changes: 111 additions & 4 deletions apps/api/tests/saml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,36 @@ import { getSamlTestKeyPair, type SamlTestKeyPair } from './helpers/saml-cert.js

const JWT_KEY = 'test-jwt-signing-key-at-least-32-chars!!';
const SLACK_TEAM_HOST = 'codeforphilly.slack.com';
/** Default SAML_ENTITY_ID per specs/api/saml.md#idp-identity-and-hosts. */
const DEFAULT_ENTITY_ID = 'https://codeforphilly.org/api/saml/slack/metadata';
/** Default CFP_SITE_HOST — the SSO endpoint Locations are built on it. */
const DEFAULT_SITE_HOST = 'codeforphilly.org';

const MD_NS = 'urn:oasis:names:tc:SAML:2.0:metadata';
const ASSERTION_NS = 'urn:oasis:names:tc:SAML:2.0:assertion';

function ssoLocations(metadataXml: string): { entityId: string | null; locations: string[] } {
const doc = new DOMParser().parseFromString(metadataXml, 'application/xml');
const root = doc.documentElement;
const locations = Array.from(root?.getElementsByTagNameNS(MD_NS, 'SingleSignOnService') ?? [])
.map((el) => el.getAttribute('Location'))
.filter((v): v is string => typeof v === 'string');
return { entityId: root?.getAttribute('entityID') ?? null, locations };
}

/** Every `<saml:Issuer>` text in a decoded SAMLResponse (Response + Assertion). */
function issuers(responseXml: string): string[] {
const doc = new DOMParser().parseFromString(responseXml, 'application/xml');
return Array.from(doc.documentElement?.getElementsByTagNameNS(ASSERTION_NS, 'Issuer') ?? []).map(
(el) => el.textContent ?? '',
);
}

function decodeSamlResponse(html: string): string {
const match = /name="SAMLResponse" value="([^"]+)"/.exec(html);
expect(match).not.toBeNull();
return Buffer.from(match![1]!, 'base64').toString('utf8');
}

async function seedPerson(
repoDir: string,
Expand Down Expand Up @@ -129,10 +159,17 @@ describe('SAML IdP — Slack', () => {
const root = doc.documentElement;
expect(root?.localName).toBe('EntityDescriptor');

// entityID present
expect(root?.getAttribute('entityID')).toBe(
`https://${SLACK_TEAM_HOST}/api/saml/slack/metadata`,
);
// entityID is the stable SAML_ENTITY_ID default — NOT built on
// SLACK_TEAM_HOST (Slack's host) and NOT on the serving host.
expect(root?.getAttribute('entityID')).toBe(DEFAULT_ENTITY_ID);

// Both SSO bindings point at our own site host.
const { locations } = ssoLocations(res.body);
expect(locations).toHaveLength(2);
for (const loc of locations) {
expect(loc).toBe(`https://${DEFAULT_SITE_HOST}/api/saml/slack/sso`);
}
expect(res.body).not.toContain(`https://${SLACK_TEAM_HOST}/api/saml`);

// IDPSSODescriptor + at least one SingleSignOnService and an X509Certificate.
const idpDescriptors = root?.getElementsByTagNameNS(
Expand Down Expand Up @@ -190,6 +227,10 @@ describe('SAML IdP — Slack', () => {
const root = doc.documentElement;
expect(root?.localName).toBe('Response');

// Issuer on both the Response and the Assertion is the entity ID — the
// same value the metadata advertises as entityID.
expect(issuers(xml)).toEqual([DEFAULT_ENTITY_ID, DEFAULT_ENTITY_ID]);

// NameID is the slackSamlNameId, format persistent
const nameIdEl = root?.getElementsByTagNameNS(
'urn:oasis:names:tc:SAML:2.0:assertion',
Expand Down Expand Up @@ -319,6 +360,72 @@ describe('SAML IdP — Slack', () => {
});
});

describe('SAML IdP — entity ID vs. site host', () => {
let dataRepo: { path: string; cleanup: () => Promise<void> };
let privateStore: { path: string; cleanup: () => Promise<void> };
let keyPair: SamlTestKeyPair;
const personId = '01951a3c-0000-7000-8000-000000000002';
const slug = 'sam';

beforeAll(async () => {
keyPair = await getSamlTestKeyPair();
dataRepo = await createFullDataRepo();
privateStore = await createPrivateStorageDir();
await seedPerson(dataRepo.path, { id: personId, slug, slackSamlNameId: slug });
await seedPrivateProfile(privateStore.path, { personId, email: 'sam@example.com' });
});

afterAll(async () => {
await dataRepo.cleanup();
await privateStore.cleanup();
});

it('CFP_SITE_HOST moves the SSO Locations but leaves entityID alone', async () => {
const app = await buildTestApp(dataRepo.path, privateStore.path, keyPair, {
CFP_SITE_HOST: 'next.example.org',
});
try {
const res = await app.inject({ method: 'GET', url: '/api/saml/slack/metadata' });
expect(res.statusCode).toBe(200);
const { entityId, locations } = ssoLocations(res.body);
expect(locations).toHaveLength(2);
for (const loc of locations) {
expect(loc).toBe('https://next.example.org/api/saml/slack/sso');
}
// The pre-cutover host does not leak into the identifier Slack stores.
expect(entityId).toBe(DEFAULT_ENTITY_ID);
} finally {
await app.close();
}
});

it('SAML_ENTITY_ID overrides both the metadata entityID and the assertion Issuer', async () => {
const entityId = 'https://idp.example.org/saml/slack';
const app = await buildTestApp(dataRepo.path, privateStore.path, keyPair, {
SAML_ENTITY_ID: entityId,
CFP_SITE_HOST: 'next.example.org',
});
try {
const meta = await app.inject({ method: 'GET', url: '/api/saml/slack/metadata' });
expect(meta.statusCode).toBe(200);
expect(ssoLocations(meta.body).entityId).toBe(entityId);

const { accessToken } = await mintSessionFor(personId, 'user', JWT_KEY);
const launch = await app.inject({
method: 'GET',
url: '/api/saml/slack/launch',
cookies: { cfp_session: accessToken },
});
expect(launch.statusCode).toBe(200);
expect(issuers(decodeSamlResponse(launch.body))).toEqual([entityId, entityId]);
// Slack-side values still come from SLACK_TEAM_HOST.
expect(launch.body).toContain(`action="https://${SLACK_TEAM_HOST}/sso/saml"`);
} finally {
await app.close();
}
});
});

describe('SAML IdP — without configured cert/key', () => {
let dataRepo: { path: string; cleanup: () => Promise<void> };
let privateStore: { path: string; cleanup: () => Promise<void> };
Expand Down
4 changes: 3 additions & 1 deletion docs/operations/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,8 @@ comments. Production pod gets these mounted:
| `CFP_DATA_BRANCH` | ConfigMap | e.g. `fixture` / `main` |
| `CFP_DATA_RELOAD_SECRET` | **Secret** | Shared bearer-token for the hot-reload webhook; when unset the `/api/_internal/reload-data` endpoint returns 503. See [runbook.md](runbook.md#hot-reload-webhook). |
| `CFP_WEB_DIST_PATH` | ConfigMap | `/app/apps/web/dist` |
| `CFP_SITE_HOST` | ConfigMap | Public-facing host (`codeforphilly.org` base, `next-v2.codeforphilly.org` sandbox). Drives the markdown renderer's external-link transform — anchors with a different host get `target="_blank" rel="noopener nofollow"`. |
| `CFP_SITE_HOST` | ConfigMap | Public-facing host (`codeforphilly.org` base, `next-v2.codeforphilly.org` sandbox). Drives the markdown renderer's external-link transform — anchors with a different host get `target="_blank" rel="noopener nofollow"` — and the SAML IdP metadata's `SingleSignOnService` endpoint URLs. |
| `SAML_ENTITY_ID` | ConfigMap | Optional. Stable SAML IdP entity ID / assertion `Issuer` (default `https://codeforphilly.org/api/saml/slack/metadata`). Leave unset everywhere Slack should keep trusting the production IdP identity — it deliberately does **not** follow `CFP_SITE_HOST`, so flipping the host at cutover doesn't require editing Slack's SAML config. Only set it when standing up a separate IdP registration (e.g. a sandbox pointed at a test workspace). See [specs/api/saml.md](../../specs/api/saml.md#idp-identity-and-hosts). |
| `POSTMARK_SERVER_TOKEN` | **Secret** | Postmark server API token for outbound notifications. When unset, the email notifier falls back to a no-op LoggingNotifier — convenient for dev + tests but means no real emails go out. |
| `POSTMARK_MESSAGE_STREAM` | ConfigMap | Postmark message stream for outbound mail (default `outbound`). Must exist on the server the token belongs to. |
| `CFP_NOTIFICATION_FROM` | ConfigMap | RFC 5322 sender address for outbound notifications (default `"Code for Philly <notifications@codeforphilly.org>"`). Sender domain must be a verified Postmark sender signature (already true for `codeforphilly.org` via the legacy site) before flipping `POSTMARK_SERVER_TOKEN` on. |
Expand All @@ -237,6 +238,7 @@ comments. Production pod gets these mounted:
| `GITHUB_OAUTH_CLIENT_SECRET` | **Secret** | OAuth app client secret |
| `CFP_JWT_SIGNING_KEY` | **Secret** | HS256 key (`openssl rand -base64 64`) |
| `SAML_PRIVATE_KEY` / `SAML_CERTIFICATE` | **Secret** | Slack IdP cert chain |
| `SLACK_TEAM_HOST` | ConfigMap | Slack workspace host (default `codeforphilly.slack.com`). ACS URL, NameID `NameQualifier`, `/chat` + `/launch` redirect target. Never our own IdP identity. |
| `GIT_SSH_COMMAND` | ConfigMap | Wires `ssh` to the mounted deploy key |

## Rollback
Expand Down
5 changes: 5 additions & 0 deletions docs/operations/secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ integration ([specs/api/saml.md](../../specs/api/saml.md)).
- **Rotation impact:** Slack stops trusting assertions until its IdP config
is updated with the new cert. **Do not rotate without coordinating with
the Slack workspace admin.**
- **Not a secret, but paired:** `SAML_ENTITY_ID` (ConfigMap, optional) is
the IdP identity Slack stores alongside this cert. It defaults to
`https://codeforphilly.org/api/saml/slack/metadata` and must stay stable
across host changes — see [deploy.md](deploy.md#environment-variables-reference)
and [specs/api/saml.md](../../specs/api/saml.md#idp-identity-and-hosts).
- **Rotation procedure:**
1. Generate new key + cert.
2. Upload the *new cert* to Slack as a secondary signing cert.
Expand Down
Loading
Loading