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
113 changes: 66 additions & 47 deletions apps/api/src/routes/chat.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
/**
* Chat redirect.
* Chat launch.
*
* GET /chat → 302 to https://<SLACK_TEAM_HOST>/
* GET /chat?channel=<name> → 302 to https://<SLACK_TEAM_HOST>/channels/<name>
* GET /chat?channel= → fall back to workspace root
* GET /chat?channel=<invalid> → fall back to root + warn log
* GET /chat → 302 to Slack SSO start, redir=/messages/general/
* GET /chat/<channel> → 302 to Slack SSO start, redir=/messages/<channel>/
* GET /chat?channel=<name> → same as the path form
* GET /chat?channel= or invalid → fall back to general (+ warn log for invalid)
*
* The target is Slack's SP-initiated SSO start URL, not the workspace: Slack
* sends an AuthnRequest to our IdP (/api/saml/slack/sso), the member signs in
* here if needed, and Slack honours `redir` to open the channel. This mirrors
* laddr's Emergence\Slack\Connector::handleLaunchRequest, which the
* codeforphilly.org/chat/<channel> links in the wild were built for.
*
* 302 (temporary) so the destination can flip later without browser caches
* sticking. Channel format matches Project.chatChannel
Expand All @@ -13,58 +19,71 @@
*
* Per specs/screens/chat.md.
*/
import type { FastifyInstance } from 'fastify';
import type { FastifyInstance, FastifyReply } from 'fastify';

const CHANNEL_REGEX = /^[a-z0-9][a-z0-9_-]{0,40}$/;
const DEFAULT_CHANNEL = 'general';

export function slackSsoStartUrl(slackHost: string, channel: string): string {
return `https://${slackHost}/sso/saml/start?redir=${encodeURIComponent(`/messages/${channel}/`)}`;
}

export async function chatRoutes(fastify: FastifyInstance): Promise<void> {
const launch = (reply: FastifyReply, requested: string | null | undefined): FastifyReply => {
const slackHost = fastify.config.SLACK_TEAM_HOST;
// Empty string is spec'd to behave like no channel — fall back to general.
let channel = requested && requested.length > 0 ? requested : DEFAULT_CHANNEL;
if (!CHANNEL_REGEX.test(channel)) {
fastify.log.warn(
// The encoded value keeps log-injection-style payloads benign.
{ channel: encodeURIComponent(channel) },
'chat launch: invalid channel format; falling back to general',
);
channel = DEFAULT_CHANNEL;
}
return reply
.code(302)
.header('Location', slackSsoStartUrl(slackHost, channel))
.header('Cache-Control', 'no-cache')
.send();
};

const querySchema = {
type: 'object',
properties: { channel: { type: 'string' } },
additionalProperties: false,
};

fastify.get(
'/chat',
{
schema: {
tags: ['chat'],
summary: 'Redirect to the Code for Philly Slack workspace',
querystring: {
type: 'object',
properties: { channel: { type: 'string' } },
additionalProperties: false,
},
summary: 'Sign into the Code for Philly Slack workspace via SSO',
querystring: querySchema,
},
},
async (request, reply) => {
const slackHost = fastify.config.SLACK_TEAM_HOST;
const root = `https://${slackHost}/`;

const raw = (request.query as { channel?: string }).channel;
// Empty string is spec'd to behave like no channel — fall back to root.
const channel = raw && raw.length > 0 ? raw : null;

if (channel === null) {
return reply
.code(302)
.header('Location', root)
.header('Cache-Control', 'no-cache')
.send();
}

if (!CHANNEL_REGEX.test(channel)) {
fastify.log.warn(
// The encoded value keeps log-injection-style payloads benign.
{ channel: encodeURIComponent(channel) },
'chat redirect: invalid channel format; falling back to root',
);
return reply
.code(302)
.header('Location', root)
.header('Cache-Control', 'no-cache')
.send();
}

return reply
.code(302)
.header('Location', `https://${slackHost}/channels/${channel}`)
.header('Cache-Control', 'no-cache')
.send();
},
async (request, reply) => launch(reply, (request.query as { channel?: string }).channel),
);

// The app does not ignore trailing slashes globally, and links in the wild
// come in both shapes, so register both.
for (const path of ['/chat/:channel', '/chat/:channel/']) {
fastify.get(
path,
{
schema: {
tags: ['chat'],
summary: 'Sign into the Code for Philly Slack workspace via SSO and open a channel',
params: {
type: 'object',
properties: { channel: { type: 'string' } },
required: ['channel'],
},
querystring: querySchema,
},
},
async (request, reply) => launch(reply, (request.params as { channel: string }).channel),
);
}
}
91 changes: 57 additions & 34 deletions apps/api/tests/chat-redirect.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
/**
* Tests for GET /chat — Slack-workspace redirect per specs/screens/chat.md.
* Tests for GET /chat and /chat/<channel> — Slack SSO launch per
* specs/screens/chat.md. Every response is a 302 to Slack's SP-initiated
* SSO start URL carrying `redir=/messages/<channel>/`.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { buildApp } from '../src/app.js';
import { slackSsoStartUrl } from '../src/routes/chat.js';
import { createFullDataRepo, createPrivateStorageDir } from './helpers/test-full-repo.js';

let dataRepo: { path: string; cleanup: () => Promise<void> };
let privateStore: { path: string; cleanup: () => Promise<void> };
let app: FastifyInstance;

const HOST = 'codeforphilly.slack.com';
const start = (channel: string): string => slackSsoStartUrl(HOST, channel);

beforeAll(async () => {
dataRepo = await createFullDataRepo();
privateStore = await createPrivateStorageDir();
Expand All @@ -20,6 +26,7 @@ beforeAll(async () => {
STORAGE_BACKEND: 'filesystem',
CFP_PRIVATE_STORAGE_PATH: privateStore.path,
CFP_JWT_SIGNING_KEY: 'test-jwt-signing-key-at-least-32-chars!!',
SLACK_TEAM_HOST: HOST,
NODE_ENV: 'test',
},
});
Expand All @@ -31,60 +38,76 @@ afterAll(async () => {
await privateStore.cleanup();
});

async function launch(url: string): Promise<string> {
const res = await app.inject({ method: 'GET', url });
expect(res.statusCode).toBe(302);
expect(res.headers['cache-control']).toBe('no-cache');
return String(res.headers.location);
}

describe('slackSsoStartUrl', () => {
it('targets Slack SSO start with an encoded /messages/<channel>/ redir', () => {
expect(start('general')).toBe(
'https://codeforphilly.slack.com/sso/saml/start?redir=%2Fmessages%2Fgeneral%2F',
);
});
});

describe('GET /chat', () => {
it('redirects to the Slack workspace root when no channel is given', async () => {
const res = await app.inject({ method: 'GET', url: '/chat' });
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe('https://codeforphilly.slack.com/');
expect(res.headers['cache-control']).toContain('no-cache');
it('launches into #general when no channel is given', async () => {
expect(await launch('/chat')).toBe(start('general'));
});

it('deep-links to a valid channel', async () => {
const res = await app.inject({ method: 'GET', url: '/chat?channel=general' });
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe('https://codeforphilly.slack.com/channels/general');
it('deep-links a valid ?channel=', async () => {
expect(await launch('/chat?channel=phlask')).toBe(start('phlask'));
});

it('accepts hyphens and underscores in the channel name', async () => {
const res = await app.inject({ method: 'GET', url: '/chat?channel=philly_civic-tech' });
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe('https://codeforphilly.slack.com/channels/philly_civic-tech');
expect(await launch('/chat?channel=philly_civic-tech')).toBe(start('philly_civic-tech'));
});

it('falls back to root for an empty channel', async () => {
const res = await app.inject({ method: 'GET', url: '/chat?channel=' });
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe('https://codeforphilly.slack.com/');
it('falls back to #general for an empty channel', async () => {
expect(await launch('/chat?channel=')).toBe(start('general'));
});

it('falls back to root for uppercase characters (invalid format)', async () => {
const res = await app.inject({ method: 'GET', url: '/chat?channel=General' });
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe('https://codeforphilly.slack.com/');
it('falls back to #general for uppercase characters (invalid format)', async () => {
expect(await launch('/chat?channel=General')).toBe(start('general'));
});

it('falls back to root for slashes (path-injection attempt)', async () => {
const res = await app.inject({ method: 'GET', url: '/chat?channel=foo%2Fbar' });
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe('https://codeforphilly.slack.com/');
it('falls back to #general for slashes (path-injection attempt)', async () => {
expect(await launch('/chat?channel=foo%2Fbar')).toBe(start('general'));
});

it('falls back to root for an over-long channel name', async () => {
// 42 chars total (> 41 max per the regex)
it('falls back to #general for an over-long channel name', async () => {
const channel = 'a'.repeat(42);
const res = await app.inject({ method: 'GET', url: `/chat?channel=${channel}` });
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe('https://codeforphilly.slack.com/');
expect(await launch(`/chat?channel=${channel}`)).toBe(start('general'));
});

it('falls back to root for leading hyphen (invalid first char)', async () => {
const res = await app.inject({ method: 'GET', url: '/chat?channel=-leading-hyphen' });
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe('https://codeforphilly.slack.com/');
it('falls back to #general for a leading hyphen (invalid first char)', async () => {
expect(await launch('/chat?channel=-leading-hyphen')).toBe(start('general'));
});

it('does not register on /api/chat (only /chat)', async () => {
const res = await app.inject({ method: 'GET', url: '/api/chat' });
expect(res.statusCode).toBe(404);
});
});

describe('GET /chat/<channel>', () => {
it('deep-links the path form — the shape of the links in the wild', async () => {
expect(await launch('/chat/phlask')).toBe(start('phlask'));
});

it('tolerates a trailing slash', async () => {
expect(await launch('/chat/phlask/')).toBe(start('phlask'));
});

it('falls back to #general for an invalid path segment', async () => {
expect(await launch('/chat/Not%20A%20Channel')).toBe(start('general'));
});

it('never lets the channel reach the host', async () => {
const location = await launch('/chat/evil.example');
expect(new URL(location).host).toBe(HOST);
});
});
51 changes: 51 additions & 0 deletions plans/chat-sso-launch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
status: done
depends: [samlify-esm-interop]
specs:
- specs/screens/chat.md
issues: []
pr: 185
---

# Plan: `/chat/<channel>` signs into Slack and opens the channel

## Scope

Years of distributed links point at `codeforphilly.org/chat/<channel>`. On
laddr that URL signed the member into Slack via SAML and landed them in the
channel. The rewrite's `/chat` only accepted `?channel=` and redirected to
`https://<team>.slack.com/channels/<name>` — which assumes an existing Slack
session — and `/chat/<channel>` fell through to the SPA's 404.

In: spec the path form and the SSO-start target; implement; tests. Out:
IdP-initiated launch (`/api/saml/slack/launch` stays as-is); any change to
the project "Chat Channel" button, which already uses `?channel=`.

## Implements

- [screens/chat.md](../specs/screens/chat.md) — redirect rules: `/chat`,
`/chat/<channel>`, `?channel=`; target is Slack's SP-initiated SSO start
with `redir=/messages/<channel>/`; default channel `general`.

## Approach

- Match laddr exactly: `Emergence\Slack\Connector::handleLaunchRequest`
redirected to `https://<team>.slack.com/sso/saml/start?redir=/messages/<channel>/`.
Slack then drives the SP-initiated flow against `/api/saml/slack/sso`,
verified live on 2026-09-18.
- `apps/api/src/routes/chat.ts`: one `launch()` helper behind `/chat` and
`/chat/:channel`; invalid or empty channel → `general` (warn log on
invalid). Fastify's default `ignoreTrailingSlash` handling covers
`/chat/foo/`.
- Tests: rewrite `chat-redirect.test.ts` expectations to the SSO-start URL
and add the path form + trailing slash + default channel.

## Validation

- `npm run type-check && npm run lint`; chat suite green.
- Live: `/chat/general` from a signed-out browser → CfP login → Slack opens
in #general.

## Follow-ups

None.
26 changes: 16 additions & 10 deletions specs/screens/chat.md
Original file line number Diff line number Diff line change
@@ -1,38 +1,44 @@
# Screen: Chat redirect
# Screen: Chat launch

## Route

`/chat` — public. Server-side redirect to the Code for Philly Slack workspace.
`/chat` and `/chat/<channel>` — public. Server-side redirect that signs the member into the Code for Philly Slack workspace and lands them in a channel.

Optional query parameter `?channel=<name>` redirects to a specific channel.
`?channel=<name>` is accepted as an alternative to the path form (the project "Chat Channel" button uses it).

## Behavior

Not a rendered screen — a redirect endpoint handled at the API layer (and aliased on the web layer for nice URLs that work without JS).

The redirect targets Slack's **SP-initiated SSO start** URL, not the workspace directly. Slack then sends a SAML AuthnRequest to our IdP (`GET | POST /api/saml/slack/sso` — see [api/saml.md](../api/saml.md)), which signs the member in on our side if needed and posts the assertion back; Slack honours `redir` and opens the channel. This is the legacy laddr behaviour (`Emergence\Slack\Connector::handleLaunchRequest`), and the `codeforphilly.org/chat/<channel>` links distributed over the years depend on it.

### Redirect rules

| Request | Redirect target | HTTP |
| ------- | --------------- | :--: |
| `/chat` | `https://codeforphilly.slack.com/` | 302 |
| `/chat?channel=foo` | `https://codeforphilly.slack.com/channels/foo` | 302 |
| `/chat` | `https://codeforphilly.slack.com/sso/saml/start?redir=%2Fmessages%2Fgeneral%2F` | 302 |
| `/chat/foo` | `https://codeforphilly.slack.com/sso/saml/start?redir=%2Fmessages%2Ffoo%2F` | 302 |
| `/chat?channel=foo` | Same as `/chat/foo` | 302 |
| `/chat/foo/` (trailing slash) | Same as `/chat/foo` | 302 |
| `/chat?channel=` (empty) | Same as `/chat` | 302 |
| `/chat?channel=<invalid format>` | Same as `/chat`, with a query log warning | 302 |
| `/chat/<invalid format>` or `?channel=<invalid format>` | Same as `/chat`, with a log warning | 302 |

The default channel is `general`, as in laddr.

`channel` is validated against the same regex as `Project.chatChannel` (`^[a-z0-9][a-z0-9_-]{0,40}$`) before interpolation, to prevent open-redirect / URL-injection on the Slack workspace URL.
`channel` is validated against the same regex as `Project.chatChannel` (`^[a-z0-9][a-z0-9_-]{0,40}$`) before interpolation, to prevent open-redirect / URL-injection on the Slack workspace URL. The host is always `SLACK_TEAM_HOST`.

Use **302** (temporary) rather than 301 so we can change the destination later without browser-cached redirects sticking.

### Why this exists

- Marketing materials and old links say "join us at codeforphilly.org/chat" — historical, do-not-break.
- Marketing materials and old links say "join us at codeforphilly.org/chat" and deep-link `codeforphilly.org/chat/<channel>` — historical, do-not-break.
- Project pages use `/chat?channel=<chatChannel>` for the "Chat Channel" button so the link looks like part of the site rather than an external Slack URL.
- If we move off Slack later, every link gets re-pointed by changing this one redirect rather than chasing references through the codebase.

## Open redirect protection

`channel` only feeds the path segment after `/channels/`; the host is hard-coded. No user input touches the host.
`channel` only feeds the `redir` value's path segment after `/messages/`; the host is hard-coded. No user input touches the host.

## Authorization

Public.
Public. The SSO round-trip that follows requires a Code for Philly sign-in, and Slack's own workspace membership rules still apply.
Loading