diff --git a/CLAUDE.md b/CLAUDE.md index 9217bc7..cd0d0bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -227,6 +227,20 @@ Activity after the fact is the only thing that separates a genuine remedy from a old thread being used to launder a fresh violation. Acknowledged, not resolved: the 1:1 still happened. +**The browser gets two pages, and the second is the same door as Slack.** +`src/web/` serves a landing page at `/` and a configuration page at `/config` +(this is what mod.redhawkrobotics.org shows). `/config` signs people in with +Slack — OpenID Connect against the same app, identity only, no stored token — +and then asks `administrator()` exactly as the slash command does, on every +request, so the cookie only says _who_ and losing Slack admin locks the page +within a minute. Writes go through `slack/settingsAdmin.ts`, the one +implementation of setting validation shared with `/hawkmod config set`; keep it +that way, or a value one door refuses becomes reachable through the other. The +session cookie and OAuth state are stateless HMAC tokens (`web/session.ts`, +signed with `SLACK_STATE_SECRET`, purpose-bound so one kind can never replay as +the other). Every string a page interpolates goes through `esc()` — setting +values and display names are whatever their owner typed. + **Two paths reach the same log.** Events (`src/slack/events.ts`) give real-time capture; the hourly backfill (`src/monitor/backfill.ts`) re-walks each adult's DM list to catch history predating enrollment and anything missed while the diff --git a/docs/deploy.md b/docs/deploy.md index 8e8f53a..94cc387 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -96,6 +96,18 @@ before setup — running and waiting. 4. Send every adult the same install URL, and watch `/hawkmod status` until coverage reads N/N. +## Web pages + +The root URL serves a landing page — what a person who types the domain into a +browser should see — with the enrollment link and a link to `/config`, a +configuration page equivalent to `/hawkmod config`. It uses Sign in with Slack +(OpenID Connect, identity only — no scopes, no stored token) and then applies +the same rule as every other entry point: workspace Owners and Admins only. +Both redirect URLs in the manifest must be registered or the matching flow +fails at Slack before anyone sees a consent screen: +`/slack/oauth_redirect` for installs and enrollment, `/auth/slack/callback` +for sign-in. + ## Upgrades Migrations are applied on boot, so an upgrade is: diff --git a/docs/slack-app-manifest.yaml b/docs/slack-app-manifest.yaml index a8d00a1..44d497f 100644 --- a/docs/slack-app-manifest.yaml +++ b/docs/slack-app-manifest.yaml @@ -28,6 +28,9 @@ features: oauth_config: redirect_urls: - https://hawk-mod.example.org/slack/oauth_redirect + # Sign in with Slack (OpenID Connect), for the web configuration page. + # Identity only — signing in grants no scopes and stores no token. + - https://hawk-mod.example.org/auth/slack/callback scopes: bot: - chat:write diff --git a/src/slack/app.ts b/src/slack/app.ts index 0c13c7b..8d6c4c9 100644 --- a/src/slack/app.ts +++ b/src/slack/app.ts @@ -8,6 +8,7 @@ import { registerCommands } from "./commands.js"; import { registerEvents } from "./events.js"; import { registerViews } from "./modals.js"; import { GROUP_ADMIN_METADATA, installationStore } from "./installStore.js"; +import { webRoutes } from "../web/routes.js"; /** Read-only apart from posting alerts. hawk-mod never needs to act as a user. */ export const BOT_SCOPES = [ @@ -122,6 +123,9 @@ export function createApp(): App { method: ["GET"], handler: authorizeGroups, }, + // The landing page and the Sign in with Slack–gated configuration page — + // what mod.redhawkrobotics.org serves to a browser. + ...webRoutes, ], redirectUri: `${cfg.PUBLIC_URL}/slack/oauth_redirect`, installerOptions: { diff --git a/src/slack/commands.ts b/src/slack/commands.ts index b7657d3..ca0694f 100644 --- a/src/slack/commands.ts +++ b/src/slack/commands.ts @@ -24,17 +24,15 @@ import { screeningStatus } from "../domain/rules/screening.js"; import { log } from "../logger.js"; import { isSettingKey, - parseHandles, SETTING_KEYS, SETTINGS, setting, settingValue, - type SettingKey, } from "../settings.js"; +import { describeValue, validateSetting } from "./settingsAdmin.js"; import { backfillAll } from "../monitor/backfill.js"; import { administrator, type Actor, NOT_PERMITTED } from "./authz.js"; import { applyGroupEdit } from "./groupAdmin.js"; -import { resolveGroup } from "./userGroups.js"; import { openConsent, openScreening } from "./modals.js"; import { runSweep } from "../jobs/sweep.js"; import { syncRolesFromUserGroups } from "../jobs/syncRoles.js"; @@ -640,35 +638,6 @@ async function configText( return lines.join("\n"); } -/** - * Renders a stored value the way a person wrote it. - * - * Channels are stored by id, deliberately — an id survives the channel being - * renamed, and a stored `#name` would quietly stop resolving the day somebody - * tidied it up. But `C0BPAV78LKZ` tells a reader nothing, so the id is what is - * kept and the name is what is shown. Falls back to the raw value if Slack - * cannot be asked: a settings listing that throws is worse than one that is - * briefly ugly. - */ -async function describeValue( - client: WebClient, - key: SettingKey, - value: string -): Promise { - if (SETTINGS[key].kind === "channel") { - try { - const info = await client.conversations.info({ channel: value }); - return info.channel?.name ? `#${info.channel.name}` : `\`${value}\``; - } catch { - return `\`${value}\``; - } - } - const handles = parseHandles(value); - return handles.length - ? handles.map((h) => `@${h}`).join(", ") - : `\`${value}\``; -} - async function configListing(client: WebClient): Promise { const rows = await Promise.all( SETTING_KEYS.map(async (key) => { @@ -698,60 +667,3 @@ async function configListing(client: WebClient): Promise { "and cannot be changed from here._", ].join("\n"); } - -/** - * Checks a value against Slack before storing it. - * - * A user group handle that does not resolve is a typo, and a stored typo reads - * exactly like an empty group: nobody rostered, nobody monitored, no complaint. - * The sweep would raise that eventually; refusing it here turns tomorrow's - * finding into an error message the person who caused it is still reading. - */ -async function validateSetting( - client: WebClient, - key: SettingKey, - raw: string -): Promise<{ value: string } | { error: string }> { - const kind = SETTINGS[key].kind; - - if (kind === "channel") { - // `<#C123|name>` when escaping is on, a bare id or #name when it is not. - const id = raw.match(/^<#([A-Z0-9]+)/i)?.[1] ?? raw.replace(/^#/, ""); - try { - const info = await client.conversations.info({ channel: id }); - if (!info.channel?.id) return { error: `No channel \`${raw}\`.` }; - return { value: info.channel.id }; - } catch (err) { - return { - error: - `Couldn't read \`${raw}\`: ${String(err)}\n` + - `hawk-mod must be a member of the channel it posts findings to.`, - }; - } - } - - const handles = raw - .split(",") - .map((h) => h.trim()) - .filter(Boolean) - .map((h) => h.match(/^]+)>$/i)?.[1] ?? h) - .map((h) => h.replace(/^@/, "")); - - if (kind === "usergroup" && handles.length !== 1) { - return { error: `\`${key}\` takes exactly one user group.` }; - } - - for (const handle of handles) { - const group = await resolveGroup(client, handle); - if (!group) { - return { - error: - `No user group @${handle} in this workspace. Nothing was changed — ` + - `a stored typo looks exactly like an empty group, which is why this ` + - `is checked before saving.`, - }; - } - } - - return { value: handles.join(",") }; -} diff --git a/src/slack/settingsAdmin.ts b/src/slack/settingsAdmin.ts new file mode 100644 index 0000000..fa8f65d --- /dev/null +++ b/src/slack/settingsAdmin.ts @@ -0,0 +1,95 @@ +import type { WebClient } from "@slack/web-api"; +import { parseHandles, SETTINGS, type SettingKey } from "../settings.js"; +import { resolveGroup } from "./userGroups.js"; + +/** + * The write path for settings, shared by the two doors that change them: the + * `/hawkmod config` slash command and the web configuration page. One + * implementation, so a value the command would refuse cannot be slipped in + * from a browser, and vice versa. + */ + +/** + * Renders a stored value the way a person wrote it. + * + * Channels are stored by id, deliberately — an id survives the channel being + * renamed, and a stored `#name` would quietly stop resolving the day somebody + * tidied it up. But `C0BPAV78LKZ` tells a reader nothing, so the id is what is + * kept and the name is what is shown. Falls back to the raw value if Slack + * cannot be asked: a settings listing that throws is worse than one that is + * briefly ugly. Plain text — the caller decides what Slack mrkdwn or HTML to + * wrap it in. + */ +export async function describeValue( + client: WebClient, + key: SettingKey, + value: string +): Promise { + if (SETTINGS[key].kind === "channel") { + try { + const info = await client.conversations.info({ channel: value }); + return info.channel?.name ? `#${info.channel.name}` : value; + } catch { + return value; + } + } + const handles = parseHandles(value); + return handles.length ? handles.map((h) => `@${h}`).join(", ") : value; +} + +/** + * Checks a value against Slack before storing it. + * + * A user group handle that does not resolve is a typo, and a stored typo reads + * exactly like an empty group: nobody rostered, nobody monitored, no complaint. + * The sweep would raise that eventually; refusing it here turns tomorrow's + * finding into an error message the person who caused it is still reading. + */ +export async function validateSetting( + client: WebClient, + key: SettingKey, + raw: string +): Promise<{ value: string } | { error: string }> { + const kind = SETTINGS[key].kind; + + if (kind === "channel") { + // `<#C123|name>` when escaping is on, a bare id or #name when it is not. + const id = raw.match(/^<#([A-Z0-9]+)/i)?.[1] ?? raw.replace(/^#/, ""); + try { + const info = await client.conversations.info({ channel: id }); + if (!info.channel?.id) return { error: `No channel \`${raw}\`.` }; + return { value: info.channel.id }; + } catch (err) { + return { + error: + `Couldn't read \`${raw}\`: ${String(err)}\n` + + `hawk-mod must be a member of the channel it posts findings to.`, + }; + } + } + + const handles = raw + .split(",") + .map((h) => h.trim()) + .filter(Boolean) + .map((h) => h.match(/^]+)>$/i)?.[1] ?? h) + .map((h) => h.replace(/^@/, "")); + + if (kind === "usergroup" && handles.length !== 1) { + return { error: `\`${key}\` takes exactly one user group.` }; + } + + for (const handle of handles) { + const group = await resolveGroup(client, handle); + if (!group) { + return { + error: + `No user group @${handle} in this workspace. Nothing was changed — ` + + `a stored typo looks exactly like an empty group, which is why this ` + + `is checked before saving.`, + }; + } + } + + return { value: handles.join(",") }; +} diff --git a/src/web/pages.ts b/src/web/pages.ts new file mode 100644 index 0000000..1f3e165 --- /dev/null +++ b/src/web/pages.ts @@ -0,0 +1,162 @@ +import { APP_NAME, BRAND, ICON_SVG } from "../brand.js"; +import { SETTINGS, type SettingKey } from "../settings.js"; + +/** + * The handful of pages hawk-mod serves to a browser: a landing page for + * whoever follows mod.redhawkrobotics.org, and the configuration page behind + * Sign in with Slack. String templates rather than a template engine — this is + * four pages, and a dependency would outweigh them. + * + * Everything interpolated from outside this file goes through `esc()`. Setting + * values, Slack display names, and error messages are all attacker-adjacent: + * a display name is whatever its owner typed. + */ + +export function esc(s: string): string { + return s + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function page(title: string, width: string, body: string): string { + return ` + + + + +${esc(title)} + +
+${ICON_SVG} +${body} +
+`; +} + +export function landingPage(): string { + return page( + APP_NAME, + "34rem", + ` +

${APP_NAME}

+

Red Hawk Robotics

+

${APP_NAME} records direct messages between adults and students in the + team Slack, so the team can meet its youth-protection obligations. Slack + cannot block those conversations on our plan; recording them for audit is + the control. Conversations with no student in them are never recorded.

+

Mentors: enrolling is what makes your DMs visible, and + it is a personal, named authorization — + enroll here. You can revoke it at any time from + Slack → Settings → Manage apps.

+

Coaches and admins: the + configuration page shows and changes which user groups + declare roles and where findings are posted. It asks you to sign in with + Slack, and is limited to workspace Owners and Admins — the same rule as + /hawkmod.

+
Day to day, ${APP_NAME} lives in Slack: + /hawkmod shows coverage, findings, and settings.
` + ); +} + +export type SettingRow = { + key: SettingKey; + /** Human-readable current value (`#alerts`, `@students`) or null if unset. */ + shown: string | null; + /** The raw stored value, prefilled into the edit box. */ + raw: string | null; + source: "slack" | "env" | "unset"; +}; + +export function configPage(args: { + rows: SettingRow[]; + signedInAs: string; + notice?: { ok: boolean; text: string }; +}): string { + const sections = args.rows + .map((row) => { + const spec = SETTINGS[row.key]; + const where = + row.source === "slack" + ? "set from Slack" + : row.source === "env" + ? `seeded from ${spec.env}` + : "not set"; + return ` +
+

${esc(spec.label)} + ${row.shown ? `— ${esc(row.shown)}` : "— not set"}

+

${row.key} · ${esc(where)} · + ${esc(spec.hint)}

+
+ + + +
+
`; + }) + .join("\n"); + + const notice = args.notice + ? `

${esc(args.notice.text)}

` + : ""; + + return page( + `Configuration — ${APP_NAME}`, + "44rem", + ` +

Configuration

+

${APP_NAME}

+ ${notice} +

Every change is validated against Slack before it is stored and recorded + in the audit trail under your name. /hawkmod config in Slack + shows and edits the same settings.

+ ${sections} +

Slack credentials and the token encryption key stay in the + environment and cannot be changed from here.

+ ` + ); +} + +/** Errors, refusals, and the occasional plain statement. */ +export function messagePage(title: string, bodyHtml: string): string { + return page( + `${title} — ${APP_NAME}`, + "34rem", + ` +

${esc(title)}

+

${APP_NAME}

+ ${bodyHtml} + ` + ); +} diff --git a/src/web/routes.ts b/src/web/routes.ts new file mode 100644 index 0000000..c08fcad --- /dev/null +++ b/src/web/routes.ts @@ -0,0 +1,367 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { randomBytes } from "node:crypto"; +import { WebClient } from "@slack/web-api"; +import type { CustomRoute } from "@slack/bolt"; +import { APP_NAME } from "../brand.js"; +import { config } from "../config.js"; +import { anyBotInstallation, setSetting } from "../db/repo.js"; +import { syncRolesFromUserGroups } from "../jobs/syncRoles.js"; +import { log } from "../logger.js"; +import { isSettingKey, SETTING_KEYS, SETTINGS, setting } from "../settings.js"; +import { administrator, NOT_PERMITTED, type Actor } from "../slack/authz.js"; +import { describeValue, validateSetting } from "../slack/settingsAdmin.js"; +import { botClient } from "../slack/tokens.js"; +import { + configPage, + landingPage, + messagePage, + type SettingRow, +} from "./pages.js"; +import { + parseCookies, + SESSION_COOKIE, + SESSION_PURPOSE, + signToken, + STATE_PURPOSE, + verifyToken, + type Session, +} from "./session.js"; + +/** + * The browser-facing routes: a landing page anyone may read, and a + * configuration page behind Sign in with Slack. + * + * The sign-in is OpenID Connect against the same Slack app — no new + * credentials, no password, no user table. Slack says who is asking; whether + * they may configure anything is then the same question every Slack entry + * point asks, answered by the same `administrator()` — Workspace Owners and + * Admins, read live. The cookie only carries identity; authority is re-checked + * on every request, so losing Slack admin locks this page within a minute. + */ + +const SESSION_TTL_MS = 8 * 60 * 60 * 1000; +const STATE_TTL_MS = 10 * 60 * 1000; + +function html(res: ServerResponse, code: number, body: string): void { + res.writeHead(code, { "content-type": "text/html; charset=utf-8" }); + res.end(body); +} + +function redirect(res: ServerResponse, to: string): void { + res.writeHead(303, { location: to }); + res.end(); +} + +function setSessionCookie(res: ServerResponse, token: string | null): void { + const secure = config().PUBLIC_URL.startsWith("https:") ? "; Secure" : ""; + res.setHeader( + "set-cookie", + token + ? `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax` + + `; Max-Age=${SESSION_TTL_MS / 1000}${secure}` + : `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}` + ); +} + +/** + * The signed-in administrator, or `null` after this has already answered the + * request — with a redirect into sign-in when there is no session, or a + * refusal when there is one but its holder is not an Owner/Admin. + */ +async function requireAdministrator( + req: IncomingMessage, + res: ServerResponse +): Promise { + const token = parseCookies(req.headers.cookie).get(SESSION_COOKIE); + const session = token + ? verifyToken( + SESSION_PURPOSE, + token, + config().SLACK_STATE_SECRET, + Date.now() + ) + : null; + if (!session) { + redirect(res, "/auth/slack"); + return null; + } + + let actor: Actor | null; + try { + actor = await administrator(botClient(), session.slackUserId); + } catch (err) { + // botClient() throws until the app is installed somewhere; there is + // nothing to configure yet either way. + html(res, 503, messagePage("Not installed yet", `

${APPROVAL_HINT}

`)); + log.warn("config page before installation", { error: String(err) }); + return null; + } + if (!actor) { + setSessionCookie(res, null); + html(res, 403, messagePage("Not permitted", `

${NOT_PERMITTED}

`)); + return null; + } + return actor; +} + +const APPROVAL_HINT = + "Hawk Mod has not been installed in the workspace yet. A workspace Owner " + + 'or Admin installs it at /slack/install; ' + + "configuration comes after that."; + +async function settingRows(client: WebClient): Promise { + return Promise.all( + SETTING_KEYS.map(async (key) => { + const { value, source } = setting(key); + return { + key, + raw: value ?? null, + shown: value ? await describeValue(client, key, value) : null, + source, + }; + }) + ); +} + +async function handleConfigGet( + req: IncomingMessage, + res: ServerResponse, + actor: Actor +): Promise { + const url = new URL(req.url ?? "/", config().PUBLIC_URL); + const ok = url.searchParams.get("ok"); + const err = url.searchParams.get("err"); + html( + res, + 200, + configPage({ + rows: await settingRows(botClient()), + signedInAs: actor.name, + notice: ok + ? { ok: true, text: ok } + : err + ? { ok: false, text: err } + : undefined, + }) + ); +} + +/** Reads a small form body; anything over 8 KB is not a settings form. */ +function readForm(req: IncomingMessage): Promise { + return new Promise((resolve) => { + let size = 0; + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > 8192) { + resolve(null); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => + resolve(new URLSearchParams(Buffer.concat(chunks).toString("utf8"))) + ); + req.on("error", () => resolve(null)); + }); +} + +async function handleConfigPost( + req: IncomingMessage, + res: ServerResponse, + actor: Actor +): Promise { + // The cookie is SameSite=Lax, which modern browsers do not send on a + // cross-site POST; the origin check catches the stragglers. Both matter: + // this form changes who is monitored. + const origin = req.headers.origin; + if (origin && origin !== new URL(config().PUBLIC_URL).origin) { + html(res, 403, messagePage("Refused", "

Cross-site request.

")); + return; + } + + const form = await readForm(req); + const key = form?.get("key") ?? ""; + const raw = (form?.get("value") ?? "").trim(); + if (!form || !isSettingKey(key)) { + html(res, 400, messagePage("Bad request", "

Unknown setting.

")); + return; + } + if (!raw) { + redirect( + res, + `/config?err=${encodeURIComponent(`Give ${key} a value — clearing a setting is not supported here.`)}` + ); + return; + } + + const client = botClient(); + const cleaned = await validateSetting(client, key, raw); + if ("error" in cleaned) { + redirect(res, `/config?err=${encodeURIComponent(cleaned.error)}`); + return; + } + + setSetting({ + key, + value: cleaned.value, + actor: actor.slackUserId, + actorName: actor.name, + }); + log.info("setting changed from web", { key, by: actor.slackUserId }); + + let note = + `${SETTINGS[key].label} is now ` + + `${await describeValue(client, key, cleaned.value)}.`; + + // Roles are read from these groups by everything downstream, so leaving the + // roster stale until 3am would mean the setting looked applied and was not. + if (key === "student-group" || key === "mentor-group") { + const stats = await syncRolesFromUserGroups(client); + note += + ` Re-synced: ${stats.created} rostered, ${stats.changed} changed, ` + + `${stats.reactivated} resumed.`; + } + + redirect(res, `/config?ok=${encodeURIComponent(note)}`); +} + +/** Sends the browser to Slack's OpenID authorize screen, with a signed state. */ +function signinHandler(_req: IncomingMessage, res: ServerResponse): void { + const cfg = config(); + const state = signToken( + STATE_PURPOSE, + { + nonce: randomBytes(16).toString("base64url"), + expiresAt: Date.now() + STATE_TTL_MS, + }, + cfg.SLACK_STATE_SECRET + ); + const url = new URL("https://slack.com/openid/connect/authorize"); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", cfg.SLACK_CLIENT_ID); + url.searchParams.set("scope", "openid"); + url.searchParams.set("redirect_uri", `${cfg.PUBLIC_URL}/auth/slack/callback`); + url.searchParams.set("state", state); + // Pins the consent screen to the installed workspace when there is one. + const team = anyBotInstallation()?.teamId; + if (team) url.searchParams.set("team", team); + res.writeHead(302, { location: url.toString() }); + res.end(); +} + +async function callbackHandler( + req: IncomingMessage, + res: ServerResponse +): Promise { + const cfg = config(); + const url = new URL(req.url ?? "/", cfg.PUBLIC_URL); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + + const stateOk = + state && + verifyToken(STATE_PURPOSE, state, cfg.SLACK_STATE_SECRET, Date.now()); + if (!code || !stateOk) { + // Ten-minute links expire; same answer the enrolment flow gives. + html( + res, + 400, + messagePage( + "That link expired", + "

Sign-in links are good for ten minutes. " + + 'Start again — nothing was changed.

' + ) + ); + return; + } + + try { + const exchange = await new WebClient().openid.connect.token({ + client_id: cfg.SLACK_CLIENT_ID, + client_secret: cfg.SLACK_CLIENT_SECRET, + code, + redirect_uri: `${cfg.PUBLIC_URL}/auth/slack/callback`, + }); + const identity = await new WebClient( + exchange.access_token + ).openid.connect.userInfo(); + const slackUserId = identity["https://slack.com/user_id"]; + const teamId = identity["https://slack.com/team_id"]; + if (!slackUserId || !teamId) throw new Error("no identity in OIDC reply"); + + // Sign-in from some other workspace is a stranger with a Slack account, + // whatever flags their own workspace gives them. + const installedTeam = anyBotInstallation()?.teamId; + if (!installedTeam || teamId !== installedTeam) { + html( + res, + 403, + messagePage( + "Wrong workspace", + `

That Slack account is not in the workspace ${APP_NAME} is ` + + "installed in.

" + ) + ); + return; + } + + const actor = await administrator(botClient(), slackUserId); + if (!actor) { + html(res, 403, messagePage("Not permitted", `

${NOT_PERMITTED}

`)); + return; + } + + const session: Session = { + slackUserId, + teamId, + expiresAt: Date.now() + SESSION_TTL_MS, + }; + setSessionCookie( + res, + signToken(SESSION_PURPOSE, session, cfg.SLACK_STATE_SECRET) + ); + redirect(res, "/config"); + log.info("administrator signed in to web config", { slackUserId }); + } catch (err) { + log.error("web sign-in failed", { error: String(err) }); + html( + res, + 500, + messagePage( + "Sign-in failed", + "

Nothing was changed. The server log has the detail; " + + 'trying again is safe.

' + ) + ); + } +} + +export const webRoutes: CustomRoute[] = [ + { + path: "/", + method: ["GET"], + handler: (_req, res) => html(res, 200, landingPage()), + }, + { + path: "/config", + method: ["GET", "POST"], + handler: async (req, res) => { + const actor = await requireAdministrator(req, res); + if (!actor) return; + if (req.method === "POST") await handleConfigPost(req, res, actor); + else await handleConfigGet(req, res, actor); + }, + }, + { path: "/auth/slack", method: ["GET"], handler: signinHandler }, + { path: "/auth/slack/callback", method: ["GET"], handler: callbackHandler }, + { + path: "/auth/signout", + method: ["GET"], + handler: (_req, res) => { + setSessionCookie(res, null); + redirect(res, "/"); + }, + }, +]; diff --git a/src/web/session.ts b/src/web/session.ts new file mode 100644 index 0000000..f38e4df --- /dev/null +++ b/src/web/session.ts @@ -0,0 +1,90 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +/** + * Signed, stateless tokens for the web pages: the OAuth `state` parameter and + * the session cookie an administrator gets after signing in with Slack. + * + * Stateless on purpose — the alternative is a sessions table that exists to be + * cleaned up. A token is `base64url(JSON payload) . HMAC(purpose + payload)`, + * so the server keeps nothing and a restart signs everyone out of nothing. + * + * Pure: the secret and the clock come in as arguments, so the tests exercise + * this without an environment. The signing key is `SLACK_STATE_SECRET`, which + * already exists to sign OAuth state and is already required to be set. + * + * `purpose` is folded into the MAC so one kind of token can never be replayed + * as another — a captured OAuth state parameter must not work as a session + * cookie, however unlikely the swap. + */ + +export type TokenPayload = { + /** Unix milliseconds. Required — every token this app signs expires. */ + expiresAt: number; + [key: string]: unknown; +}; + +export function signToken( + purpose: string, + payload: TokenPayload, + secret: string +): string { + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + const mac = createHmac("sha256", secret) + .update(`${purpose}.${body}`) + .digest("base64url"); + return `${body}.${mac}`; +} + +export function verifyToken( + purpose: string, + token: string, + secret: string, + now: number +): T | null { + const dot = token.lastIndexOf("."); + if (dot < 1) return null; + const body = token.slice(0, dot); + const mac = token.slice(dot + 1); + const expected = createHmac("sha256", secret) + .update(`${purpose}.${body}`) + .digest("base64url"); + const a = Buffer.from(mac); + const b = Buffer.from(expected); + if (a.length !== b.length || !timingSafeEqual(a, b)) return null; + + let payload: T; + try { + payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")); + } catch { + return null; + } + if (typeof payload?.expiresAt !== "number" || payload.expiresAt <= now) { + return null; + } + return payload; +} + +/** What the session cookie carries. The admin check itself is re-run per + * request against Slack (`administrator()`), so this only says who is asking — + * losing Slack admin revokes access within a minute, cookie or no cookie. */ +export type Session = TokenPayload & { + slackUserId: string; + teamId: string; +}; + +export const SESSION_COOKIE = "hawkmod_session"; +export const SESSION_PURPOSE = "web-session"; +export const STATE_PURPOSE = "web-oauth-state"; + +/** Minimal `Cookie:` header parser — two known cookies, no library. */ +export function parseCookies(header: string | undefined): Map { + const out = new Map(); + for (const part of (header ?? "").split(";")) { + const eq = part.indexOf("="); + if (eq < 0) continue; + const name = part.slice(0, eq).trim(); + const value = part.slice(eq + 1).trim(); + if (name) out.set(name, value); + } + return out; +} diff --git a/test/webSession.test.ts b/test/webSession.test.ts new file mode 100644 index 0000000..f083be2 --- /dev/null +++ b/test/webSession.test.ts @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + parseCookies, + signToken, + verifyToken, + type Session, +} from "../src/web/session.js"; + +const SECRET = "correct-horse-battery-staple"; +const NOW = 1_700_000_000_000; + +function freshSession(): Session { + return { slackUserId: "U123", teamId: "T123", expiresAt: NOW + 60_000 }; +} + +describe("web session tokens", () => { + it("round-trips a payload", () => { + const token = signToken("web-session", freshSession(), SECRET); + const back = verifyToken("web-session", token, SECRET, NOW); + assert.equal(back?.slackUserId, "U123"); + assert.equal(back?.teamId, "T123"); + }); + + it("rejects an expired token", () => { + const token = signToken("web-session", freshSession(), SECRET); + assert.equal(verifyToken("web-session", token, SECRET, NOW + 60_001), null); + }); + + it("rejects a tampered payload", () => { + const token = signToken("web-session", freshSession(), SECRET); + const [body, mac] = token.split("."); + const forged = Buffer.from( + JSON.stringify({ ...freshSession(), slackUserId: "UEVIL" }) + ).toString("base64url"); + assert.equal( + verifyToken("web-session", `${forged}.${mac}`, SECRET, NOW), + null + ); + // The untampered halves still verify, so the test can't pass vacuously. + assert.ok(verifyToken("web-session", `${body}.${mac}`, SECRET, NOW)); + }); + + it("rejects the wrong secret", () => { + const token = signToken("web-session", freshSession(), SECRET); + assert.equal( + verifyToken("web-session", token, "another-secret", NOW), + null + ); + }); + + it("binds a token to its purpose — state cannot become a session", () => { + const token = signToken("web-oauth-state", freshSession(), SECRET); + assert.equal(verifyToken("web-session", token, SECRET, NOW), null); + }); + + it("rejects garbage without throwing", () => { + for (const junk of ["", ".", "a.b", "not-a-token", "aaaa"]) { + assert.equal(verifyToken("web-session", junk, SECRET, NOW), null); + } + }); + + it("rejects a payload with no expiry", () => { + const body = Buffer.from(JSON.stringify({ slackUserId: "U1" })).toString( + "base64url" + ); + // Signed the same way signToken would, but expiresAt is absent. + const forged = signToken( + "web-session", + { expiresAt: NOW + 1000 }, + SECRET + ).replace(/^[^.]+/, body); + assert.equal(verifyToken("web-session", forged, SECRET, NOW), null); + }); +}); + +describe("cookie parsing", () => { + it("finds a cookie among several", () => { + const jar = parseCookies("a=1; hawkmod_session=abc.def; b=2"); + assert.equal(jar.get("hawkmod_session"), "abc.def"); + }); + + it("tolerates a missing header", () => { + assert.equal(parseCookies(undefined).get("hawkmod_session"), undefined); + }); +});