diff --git a/src/auth/schema.ts b/src/auth/schema.ts index 4528041..35e7fa1 100644 --- a/src/auth/schema.ts +++ b/src/auth/schema.ts @@ -1,4 +1,17 @@ import { z } from "zod"; +import { + normalizeSlackWorkspaceUrl, + SLACK_WORKSPACE_ORIGIN_ERROR, +} from "../slack/workspace-url.ts"; + +const SlackWorkspaceUrlSchema = z.string().transform((value, ctx) => { + try { + return normalizeSlackWorkspaceUrl(value); + } catch { + ctx.addIssue({ code: "custom", message: SLACK_WORKSPACE_ORIGIN_ERROR }); + return z.NEVER; + } +}); export const WorkspaceAuthSchema = z.union([ z.object({ @@ -15,7 +28,7 @@ export const WorkspaceAuthSchema = z.union([ export type WorkspaceAuth = z.infer; export const WorkspaceSchema = z.object({ - workspace_url: z.string().url(), + workspace_url: SlackWorkspaceUrlSchema, workspace_name: z.string().optional(), team_id: z.string().optional(), team_domain: z.string().optional(), diff --git a/src/auth/store.ts b/src/auth/store.ts index 275dc0c..3f2d533 100644 --- a/src/auth/store.ts +++ b/src/auth/store.ts @@ -1,35 +1,57 @@ import { CREDENTIALS_FILE, KEYCHAIN_SERVICE } from "./paths.ts"; -import { readJsonFile, writeJsonFile } from "../lib/fs.ts"; +import { writeJsonFile } from "../lib/fs.ts"; import { CredentialsSchema, type Credentials, type Workspace } from "./schema.ts"; import { keychainGet, keychainSet } from "./keychain.ts"; import { platform } from "node:os"; +import { readFile } from "node:fs/promises"; +import { isRecord } from "../lib/object-type-guards.ts"; +import { normalizeSlackWorkspaceUrl } from "../slack/workspace-url.ts"; const KEYCHAIN_PLACEHOLDER = "__KEYCHAIN__"; const IS_MACOS = platform() === "darwin"; +const INVALID_STORED_CREDENTIALS_ERROR = + "Stored credentials are invalid; refusing to use or overwrite them."; -function normalizeWorkspaceUrl(workspaceUrl: string): string { - const u = new URL(workspaceUrl); - return `${u.protocol}//${u.host}`; +function browserCookieAccount(workspaceUrl: string): string { + return `xoxd:${normalizeSlackWorkspaceUrl(workspaceUrl)}`; } function isPlaceholderSecret(value: string | undefined): boolean { return !value || value === KEYCHAIN_PLACEHOLDER; } -export async function loadCredentials(): Promise { - const fromFile = await readJsonFile(CREDENTIALS_FILE); - const parsed = CredentialsSchema.safeParse(fromFile ?? { version: 1, workspaces: [] }); - if (!parsed.success) { - return { version: 1, workspaces: [] }; +export async function readStoredCredentials( + credentialsFile: string = CREDENTIALS_FILE, +): Promise { + let raw: string; + try { + raw = await readFile(credentialsFile, "utf8"); + } catch (error) { + if (isRecord(error) && error.code === "ENOENT") { + return { version: 1, workspaces: [] }; + } + throw error; + } + + try { + return CredentialsSchema.parse(JSON.parse(raw)); + } catch { + throw new Error(INVALID_STORED_CREDENTIALS_ERROR); } +} +export async function loadCredentials(options?: { + credentialsFile?: string; + keychainRead?: typeof keychainGet; +}): Promise { // Optional: hydrate browser cookie/token from keychain for security. - const creds = parsed.data; + const creds = await readStoredCredentials(options?.credentialsFile); + const readKeychain = options?.keychainRead ?? keychainGet; const hydrated = creds.workspaces.map((w) => { if (w.auth.auth_type === "browser") { - const account = `xoxc:${normalizeWorkspaceUrl(w.workspace_url)}`; - const xoxc = keychainGet(account, KEYCHAIN_SERVICE); - const xoxd = keychainGet("xoxd", KEYCHAIN_SERVICE); + const account = `xoxc:${normalizeSlackWorkspaceUrl(w.workspace_url)}`; + const xoxc = readKeychain(account, KEYCHAIN_SERVICE); + const xoxd = readKeychain(browserCookieAccount(w.workspace_url), KEYCHAIN_SERVICE); return { ...w, auth: { @@ -40,8 +62,8 @@ export async function loadCredentials(): Promise { }; } if (w.auth.auth_type === "standard") { - const account = `token:${normalizeWorkspaceUrl(w.workspace_url)}`; - const token = keychainGet(account, KEYCHAIN_SERVICE); + const account = `token:${normalizeSlackWorkspaceUrl(w.workspace_url)}`; + const token = readKeychain(account, KEYCHAIN_SERVICE); return { ...w, auth: { @@ -57,55 +79,43 @@ export async function loadCredentials(): Promise { } export async function saveCredentials(credentials: Credentials): Promise { - const payload: Credentials = { + const payload = CredentialsSchema.parse({ ...credentials, updated_at: new Date().toISOString(), - workspaces: credentials.workspaces.map((w) => ({ - ...w, - workspace_url: normalizeWorkspaceUrl(w.workspace_url), - })), - }; + }); // Store secrets in keychain when possible and avoid writing plaintext tokens to disk. // If keychain writes fail (or non-macOS), fall back to storing secrets in the file. const filePayload: Credentials = structuredClone(payload); if (IS_MACOS) { - // Browser auth: xoxd is shared across workspaces, xoxc is per-workspace. - const firstBrowser = payload.workspaces.find((w) => w.auth.auth_type === "browser"); - let xoxdStored = false; - if ( - firstBrowser?.auth.auth_type === "browser" && - !isPlaceholderSecret(firstBrowser.auth.xoxd_cookie) - ) { - const existing = keychainGet("xoxd", KEYCHAIN_SERVICE); - xoxdStored = - existing === firstBrowser.auth.xoxd_cookie || - keychainSet({ - account: "xoxd", - value: firstBrowser.auth.xoxd_cookie, - service: KEYCHAIN_SERVICE, - }); - } - for (const w of filePayload.workspaces) { if (w.auth.auth_type === "browser") { - const account = `xoxc:${normalizeWorkspaceUrl(w.workspace_url)}`; + const account = `xoxc:${normalizeSlackWorkspaceUrl(w.workspace_url)}`; const tokenStored = isPlaceholderSecret(w.auth.xoxc_token) || keychainGet(account, KEYCHAIN_SERVICE) === w.auth.xoxc_token || keychainSet({ account, value: w.auth.xoxc_token, service: KEYCHAIN_SERVICE }); + const cookieAccount = browserCookieAccount(w.workspace_url); + const cookieStored = + isPlaceholderSecret(w.auth.xoxd_cookie) || + keychainGet(cookieAccount, KEYCHAIN_SERVICE) === w.auth.xoxd_cookie || + keychainSet({ + account: cookieAccount, + value: w.auth.xoxd_cookie, + service: KEYCHAIN_SERVICE, + }); if (tokenStored) { w.auth.xoxc_token = KEYCHAIN_PLACEHOLDER; } - if (xoxdStored) { + if (cookieStored) { w.auth.xoxd_cookie = KEYCHAIN_PLACEHOLDER; } } if (w.auth.auth_type === "standard") { - const account = `token:${normalizeWorkspaceUrl(w.workspace_url)}`; + const account = `token:${normalizeSlackWorkspaceUrl(w.workspace_url)}`; const tokenStored = isPlaceholderSecret(w.auth.token) || keychainGet(account, KEYCHAIN_SERVICE) === w.auth.token || @@ -122,12 +132,10 @@ export async function saveCredentials(credentials: Credentials): Promise { export async function upsertWorkspace(workspace: Workspace): Promise { const creds = await loadCredentials(); - const normalizedUrl = normalizeWorkspaceUrl(workspace.workspace_url); + const normalizedUrl = normalizeSlackWorkspaceUrl(workspace.workspace_url); const next: Workspace = { ...workspace, workspace_url: normalizedUrl }; - const idx = creds.workspaces.findIndex( - (w) => normalizeWorkspaceUrl(w.workspace_url) === normalizedUrl, - ); + const idx = creds.workspaces.findIndex((w) => w.workspace_url === normalizedUrl); if (idx === -1) { creds.workspaces.push(next); } else { @@ -152,12 +160,10 @@ export async function upsertWorkspaces(workspaces: Workspace[]): Promise { const creds = await loadCredentials(); for (const workspace of workspaces) { - const normalizedUrl = normalizeWorkspaceUrl(workspace.workspace_url); + const normalizedUrl = normalizeSlackWorkspaceUrl(workspace.workspace_url); const next: Workspace = { ...workspace, workspace_url: normalizedUrl }; - const idx = creds.workspaces.findIndex( - (w) => normalizeWorkspaceUrl(w.workspace_url) === normalizedUrl, - ); + const idx = creds.workspaces.findIndex((w) => w.workspace_url === normalizedUrl); if (idx === -1) { creds.workspaces.push(next); } else { @@ -178,16 +184,14 @@ export async function upsertWorkspaces(workspaces: Workspace[]): Promise { export async function setDefaultWorkspace(workspaceUrl: string): Promise { const creds = await loadCredentials(); - creds.default_workspace_url = normalizeWorkspaceUrl(workspaceUrl); + creds.default_workspace_url = normalizeSlackWorkspaceUrl(workspaceUrl); await saveCredentials(creds); } export async function removeWorkspace(workspaceUrl: string): Promise { const creds = await loadCredentials(); - const normalized = normalizeWorkspaceUrl(workspaceUrl); - creds.workspaces = creds.workspaces.filter( - (w) => normalizeWorkspaceUrl(w.workspace_url) !== normalized, - ); + const normalized = normalizeSlackWorkspaceUrl(workspaceUrl); + creds.workspaces = creds.workspaces.filter((w) => w.workspace_url !== normalized); if (creds.default_workspace_url === normalized) { creds.default_workspace_url = creds.workspaces[0]?.workspace_url; } @@ -196,10 +200,8 @@ export async function removeWorkspace(workspaceUrl: string): Promise { export async function resolveWorkspaceForUrl(workspaceUrl: string): Promise { const creds = await loadCredentials(); - const normalized = normalizeWorkspaceUrl(workspaceUrl); - return ( - creds.workspaces.find((w) => normalizeWorkspaceUrl(w.workspace_url) === normalized) ?? null - ); + const normalized = normalizeSlackWorkspaceUrl(workspaceUrl); + return creds.workspaces.find((w) => w.workspace_url === normalized) ?? null; } export async function resolveDefaultWorkspace(): Promise { diff --git a/src/cli/auth-command.ts b/src/cli/auth-command.ts index f4f3127..d41d332 100644 --- a/src/cli/auth-command.ts +++ b/src/cli/auth-command.ts @@ -85,8 +85,8 @@ export function registerAuthCommand(input: { program: Command; ctx: CliContext } ); } - for (const team of extracted.teams) { - await upsertWorkspace({ + await upsertWorkspaces( + extracted.teams.map((team) => ({ workspace_url: input.ctx.normalizeUrl(team.url), workspace_name: team.name, auth: { @@ -94,8 +94,8 @@ export function registerAuthCommand(input: { program: Command; ctx: CliContext } xoxc_token: team.token, xoxd_cookie: extracted.cookie_d, }, - }); - } + })), + ); console.log(`Imported ${extracted.teams.length} workspace token(s) from Chrome.`); } catch (err: unknown) { console.error(input.ctx.errorMessage(err)); @@ -118,8 +118,8 @@ export function registerAuthCommand(input: { program: Command; ctx: CliContext } ); } - for (const team of extracted.teams) { - await upsertWorkspace({ + await upsertWorkspaces( + extracted.teams.map((team) => ({ workspace_url: input.ctx.normalizeUrl(team.url), workspace_name: team.name, auth: { @@ -127,8 +127,8 @@ export function registerAuthCommand(input: { program: Command; ctx: CliContext } xoxc_token: team.token, xoxd_cookie: extracted.cookie_d, }, - }); - } + })), + ); console.log(`Imported ${extracted.teams.length} workspace token(s) from Brave.`); } catch (err: unknown) { console.error(input.ctx.errorMessage(err)); @@ -148,8 +148,8 @@ export function registerAuthCommand(input: { program: Command; ctx: CliContext } ); } - for (const team of extracted.teams) { - await upsertWorkspace({ + await upsertWorkspaces( + extracted.teams.map((team) => ({ workspace_url: input.ctx.normalizeUrl(team.url), workspace_name: team.name, auth: { @@ -157,8 +157,8 @@ export function registerAuthCommand(input: { program: Command; ctx: CliContext } xoxc_token: team.token, xoxd_cookie: extracted.cookie_d, }, - }); - } + })), + ); console.log(`Imported ${extracted.teams.length} workspace token(s) from Firefox.`); } catch (err: unknown) { console.error(input.ctx.errorMessage(err)); diff --git a/src/cli/context-client-resolver.ts b/src/cli/context-client-resolver.ts index d9beb8b..60075f1 100644 --- a/src/cli/context-client-resolver.ts +++ b/src/cli/context-client-resolver.ts @@ -11,18 +11,19 @@ import { } from "../auth/store.ts"; import { resolveWorkspaceSelector } from "./workspace-selector.ts"; import { SlackApiClient, type SlackAuth } from "../slack/client.ts"; +import { normalizeSlackWorkspaceUrl } from "../slack/workspace-url.ts"; export function normalizeUrl(u: string): string { - const url = new URL(u); - return `${url.protocol}//${url.host}`; + return normalizeSlackWorkspaceUrl(u); } function tryNormalizeUrl(u: string): string | undefined { try { - return normalizeUrl(u); + new URL(u); } catch { return undefined; } + return normalizeUrl(u); } function pickAuthFromEnv(): SlackAuth | null { @@ -67,12 +68,13 @@ export async function getClientForWorkspace(workspaceUrl?: string): Promise<{ const env = pickAuthFromEnv(); if (env) { - const envWorkspaceUrl = process.env.SLACK_WORKSPACE_URL?.trim(); - const urlForBrowser = resolvedWorkspaceUrl || envWorkspaceUrl; + const rawEnvWorkspaceUrl = process.env.SLACK_WORKSPACE_URL?.trim(); + const urlForClient = + resolvedWorkspaceUrl ?? (rawEnvWorkspaceUrl ? normalizeUrl(rawEnvWorkspaceUrl) : undefined); return { - client: new SlackApiClient(env, { workspaceUrl: urlForBrowser }), + client: new SlackApiClient(env, { workspaceUrl: urlForClient }), auth: env, - workspace_url: urlForBrowser, + workspace_url: urlForClient, }; } diff --git a/src/slack/client.ts b/src/slack/client.ts index fad8cbd..3601a2a 100644 --- a/src/slack/client.ts +++ b/src/slack/client.ts @@ -1,5 +1,10 @@ import { WebClient } from "@slack/web-api"; import { getUserAgent } from "../lib/version.ts"; +import { + normalizeSlackWorkspaceUrl, + slackApiUrlForWorkspace, + slackAppOriginForWorkspace, +} from "./workspace-url.ts"; export type SlackAuth = | { auth_type: "standard"; token: string } @@ -76,9 +81,15 @@ export class SlackApiClient { constructor(auth: SlackAuth, options?: { workspaceUrl?: string }) { this.auth = auth; - this.workspaceUrl = options?.workspaceUrl; + this.workspaceUrl = options?.workspaceUrl + ? normalizeSlackWorkspaceUrl(options.workspaceUrl) + : undefined; if (auth.auth_type === "standard") { this.web = new WebClient(auth.token, { + slackApiUrl: this.workspaceUrl + ? slackApiUrlForWorkspace(this.workspaceUrl) + : "https://slack.com/api/", + allowAbsoluteUrls: false, timeout: getSlackApiTimeoutMs(), retryConfig: { retries: 0 }, rejectRateLimitedCalls: true, @@ -119,7 +130,8 @@ export class SlackApiClient { attempt?: number; }): Promise> { const attempt = input.attempt ?? 0; - const url = `${input.workspaceUrl.replace(/\/$/, "")}/api/${input.method}`; + const workspaceUrl = normalizeSlackWorkspaceUrl(input.workspaceUrl); + const url = `${workspaceUrl}/api/${input.method}`; const fd = new FormData(); fd.append("token", input.auth.xoxc_token); for (const [k, v] of Object.entries(input.params)) { @@ -132,9 +144,10 @@ export class SlackApiClient { try { response = await fetch(url, { method: "POST", + redirect: "error", headers: { Cookie: `d=${encodeURIComponent(input.auth.xoxd_cookie)}`, - Origin: "https://app.slack.com", + Origin: slackAppOriginForWorkspace(workspaceUrl), "User-Agent": getUserAgent(), }, body: fd, @@ -208,7 +221,8 @@ export class SlackApiClient { attempt?: number; }): Promise> { const attempt = input.attempt ?? 0; - const url = `${input.workspaceUrl.replace(/\/$/, "")}/api/${input.method}`; + const workspaceUrl = normalizeSlackWorkspaceUrl(input.workspaceUrl); + const url = `${workspaceUrl}/api/${input.method}`; const cleanedEntries = Object.entries(input.params) .filter(([, v]) => v !== undefined) .map(([k, v]) => [k, typeof v === "object" ? JSON.stringify(v) : String(v)]); @@ -221,10 +235,11 @@ export class SlackApiClient { try { response = await fetch(url, { method: "POST", + redirect: "error", headers: { Cookie: `d=${encodeURIComponent(input.auth.xoxd_cookie)}`, "Content-Type": "application/x-www-form-urlencoded", - Origin: "https://app.slack.com", + Origin: slackAppOriginForWorkspace(workspaceUrl), "User-Agent": getUserAgent(), }, body: formBody, diff --git a/src/slack/workspace-url.ts b/src/slack/workspace-url.ts new file mode 100644 index 0000000..63f3cbf --- /dev/null +++ b/src/slack/workspace-url.ts @@ -0,0 +1,64 @@ +export const SLACK_WORKSPACE_ORIGIN_ERROR = + "Workspace URL must be a canonical HTTPS Slack or GovSlack origin " + + "(https://.slack.com or https://.slack-gov.com)."; + +type SlackRealm = "commercial" | "gov"; + +function isSlackOwnedHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + const suffix = normalized.endsWith(".slack-gov.com") + ? ".slack-gov.com" + : normalized.endsWith(".slack.com") + ? ".slack.com" + : null; + if (!suffix || normalized.length > 253) { + return false; + } + + const workspace = normalized.slice(0, -suffix.length); + return ( + workspace.length > 0 && + workspace.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) + ); +} + +export function normalizeSlackWorkspaceUrl(input: string): string { + let url: URL; + try { + url = new URL(input); + } catch { + throw new Error(SLACK_WORKSPACE_ORIGIN_ERROR); + } + + if ( + url.protocol !== "https:" || + url.username !== "" || + url.password !== "" || + url.port !== "" || + (url.pathname !== "" && url.pathname !== "/") || + url.search !== "" || + url.hash !== "" || + !isSlackOwnedHostname(url.hostname) + ) { + throw new Error(SLACK_WORKSPACE_ORIGIN_ERROR); + } + + return url.origin; +} + +export function slackRealmForWorkspaceUrl(workspaceUrl: string): SlackRealm { + const { hostname } = new URL(normalizeSlackWorkspaceUrl(workspaceUrl)); + return hostname.endsWith(".slack-gov.com") ? "gov" : "commercial"; +} + +export function slackAppOriginForWorkspace(workspaceUrl: string): string { + return slackRealmForWorkspaceUrl(workspaceUrl) === "gov" + ? "https://app.slack-gov.com" + : "https://app.slack.com"; +} + +export function slackApiUrlForWorkspace(workspaceUrl: string): string { + return slackRealmForWorkspaceUrl(workspaceUrl) === "gov" + ? "https://slack-gov.com/api/" + : "https://slack.com/api/"; +} diff --git a/test/client.test.ts b/test/client.test.ts index c7e395d..cc5ce54 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -10,6 +10,74 @@ afterEach(() => { delete process.env.AGENT_SLACK_RATE_LIMIT_MAX_WAIT_MS; }); +function browserAuth() { + return { + auth_type: "browser" as const, + xoxc_token: "xoxc-test", + xoxd_cookie: "xoxd-test", + }; +} + +describe("SlackApiClient credential destinations", () => { + test("rejects unsafe workspace origins before browser credentials can be sent", () => { + const fetchMock = mock(async () => new Response(JSON.stringify({ ok: true }))); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + expect( + () => new SlackApiClient(browserAuth(), { workspaceUrl: "https://collector.example" }), + ).toThrow("canonical HTTPS Slack or GovSlack origin"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("revalidates immediately before both browser transports", async () => { + const fetchMock = mock(async () => new Response(JSON.stringify({ ok: true }))); + globalThis.fetch = fetchMock as unknown as typeof fetch; + const client = new SlackApiClient(browserAuth(), { + workspaceUrl: "https://workspace.slack.com", + }); + + (client as unknown as { workspaceUrl: string }).workspaceUrl = "https://collector.example"; + + await expect(client.api("auth.test")).rejects.toThrow( + "canonical HTTPS Slack or GovSlack origin", + ); + await expect(client.apiMultipart("files.createCanvas")).rejects.toThrow( + "canonical HTTPS Slack or GovSlack origin", + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("uses GovSlack browser and standard-token destinations", async () => { + const fetchMock = mock( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + const browserClient = new SlackApiClient(browserAuth(), { + workspaceUrl: "https://AGENCY.slack-gov.com/", + }); + + await expect(browserClient.api("auth.test")).resolves.toEqual({ ok: true }); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe("https://agency.slack-gov.com/api/auth.test"); + expect(init).toMatchObject({ + redirect: "error", + headers: { Origin: "https://app.slack-gov.com" }, + }); + + const standardClient = new SlackApiClient( + { auth_type: "standard", token: "xoxb-test" }, + { workspaceUrl: "https://agency.slack-gov.com" }, + ); + expect((standardClient as unknown as { web: { slackApiUrl: string } }).web.slackApiUrl).toBe( + "https://slack-gov.com/api/", + ); + }); +}); + describe("SlackApiClient browser multipart transport", () => { test("retries HTTP 429 responses using Retry-After", async () => { // Fail-fast defaults to 0ms; opt in to waiting so the retry path runs. @@ -35,14 +103,9 @@ describe("SlackApiClient browser multipart transport", () => { return 0; }) as unknown as typeof setTimeout; - const client = new SlackApiClient( - { - auth_type: "browser", - xoxc_token: "xoxc-test", - xoxd_cookie: "xoxd-test", - }, - { workspaceUrl: "https://workspace.slack.com" }, - ); + const client = new SlackApiClient(browserAuth(), { + workspaceUrl: "https://workspace.slack.com", + }); await expect( client.apiMultipart("files.createCanvas", { @@ -55,6 +118,7 @@ describe("SlackApiClient browser multipart transport", () => { expect(delays).toEqual([2000]); for (const call of fetchMock.mock.calls) { expect(call[1]?.body).toBeInstanceOf(FormData); + expect(call[1]?.redirect).toBe("error"); } }); }); diff --git a/test/context-client-resolver.test.ts b/test/context-client-resolver.test.ts new file mode 100644 index 0000000..447c7b7 --- /dev/null +++ b/test/context-client-resolver.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { getClientForWorkspace } from "../src/cli/context-client-resolver.ts"; + +const originalSlackEnv = { + token: process.env.SLACK_TOKEN, + cookieD: process.env.SLACK_COOKIE_D, + workspaceUrl: process.env.SLACK_WORKSPACE_URL, +}; + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} + +afterEach(() => { + restoreEnv("SLACK_TOKEN", originalSlackEnv.token); + restoreEnv("SLACK_COOKIE_D", originalSlackEnv.cookieD); + restoreEnv("SLACK_WORKSPACE_URL", originalSlackEnv.workspaceUrl); +}); + +describe("environment workspace validation", () => { + test("rejects an unsafe origin before returning a browser client", async () => { + process.env.SLACK_TOKEN = "xoxc-test"; + process.env.SLACK_COOKIE_D = "xoxd-test"; + process.env.SLACK_WORKSPACE_URL = "https://collector.example"; + + await expect(getClientForWorkspace()).rejects.toThrow( + "canonical HTTPS Slack or GovSlack origin", + ); + }); + + test("canonicalizes a GovSlack origin for standard tokens", async () => { + process.env.SLACK_TOKEN = "xoxb-test"; + process.env.SLACK_WORKSPACE_URL = "https://AGENCY.slack-gov.com/"; + + await expect(getClientForWorkspace()).resolves.toMatchObject({ + workspace_url: "https://agency.slack-gov.com", + auth: { auth_type: "standard" }, + }); + }); + + test("does not validate an unused env URL when an explicit workspace wins", async () => { + process.env.SLACK_TOKEN = "xoxb-test"; + process.env.SLACK_WORKSPACE_URL = "https://collector.example"; + + await expect(getClientForWorkspace("https://workspace.slack.com")).resolves.toMatchObject({ + workspace_url: "https://workspace.slack.com", + auth: { auth_type: "standard" }, + }); + }); +}); diff --git a/test/workspace-url.test.ts b/test/workspace-url.test.ts new file mode 100644 index 0000000..7d8efdd --- /dev/null +++ b/test/workspace-url.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CredentialsSchema, WorkspaceSchema } from "../src/auth/schema.ts"; +import { loadCredentials, readStoredCredentials } from "../src/auth/store.ts"; +import { + normalizeSlackWorkspaceUrl, + slackApiUrlForWorkspace, + slackAppOriginForWorkspace, +} from "../src/slack/workspace-url.ts"; + +describe("Slack workspace origins", () => { + test("canonicalizes commercial, Enterprise, and GovSlack origins", () => { + expect(normalizeSlackWorkspaceUrl("https://TEAM.slack.com/")).toBe("https://team.slack.com"); + expect(normalizeSlackWorkspaceUrl("https://acme.enterprise.slack.com")).toBe( + "https://acme.enterprise.slack.com", + ); + expect(normalizeSlackWorkspaceUrl("https://AGENCY.slack-gov.com/")).toBe( + "https://agency.slack-gov.com", + ); + }); + + test("rejects origins outside the Slack credential boundary", () => { + for (const value of [ + "http://team.slack.com", + "https://example.com", + "https://team.slack.com.evil.test", + "https://slack.com", + "https://user:password@team.slack.com", + "https://team.slack.com:8443", + "https://team.slack.com/archives/C123", + "https://team.slack.com?token=secret", + "https://team.slack.com#fragment", + ]) { + expect(() => normalizeSlackWorkspaceUrl(value), value).toThrow( + "canonical HTTPS Slack or GovSlack origin", + ); + } + }); + + test("routes each accepted realm to its fixed app and API origins", () => { + expect(slackAppOriginForWorkspace("https://team.slack.com")).toBe("https://app.slack.com"); + expect(slackApiUrlForWorkspace("https://team.slack.com")).toBe("https://slack.com/api/"); + expect(slackAppOriginForWorkspace("https://agency.slack-gov.com")).toBe( + "https://app.slack-gov.com", + ); + expect(slackApiUrlForWorkspace("https://agency.slack-gov.com")).toBe( + "https://slack-gov.com/api/", + ); + }); +}); + +describe("credential ingestion", () => { + const browserAuth = { + auth_type: "browser" as const, + xoxc_token: "xoxc-test", + xoxd_cookie: "xoxd-test", + }; + + test("canonicalizes valid workspace records and rejects a mixed unsafe store", () => { + expect( + WorkspaceSchema.parse({ workspace_url: "https://TEAM.slack.com/", auth: browserAuth }) + .workspace_url, + ).toBe("https://team.slack.com"); + expect( + CredentialsSchema.safeParse({ + version: 1, + workspaces: [ + { workspace_url: "https://team.slack.com", auth: browserAuth }, + { workspace_url: "https://team.slack.com.evil.test", auth: browserAuth }, + ], + }).success, + ).toBe(false); + }); + + test("keeps an irrelevant legacy default from invalidating safe workspaces", () => { + expect( + CredentialsSchema.parse({ + version: 1, + default_workspace_url: "http://legacy.slack.com", + workspaces: [{ workspace_url: "https://TEAM.slack.com/", auth: browserAuth }], + }), + ).toMatchObject({ + default_workspace_url: "http://legacy.slack.com", + workspaces: [{ workspace_url: "https://team.slack.com" }], + }); + }); + + test("hydrates only workspace-scoped browser cookies", async () => { + const dir = await mkdtemp(join(tmpdir(), "agent-slack-credentials-")); + const credentialsFile = join(dir, "credentials.json"); + const browserWorkspace = (workspace_url: string) => ({ + workspace_url, + auth: { + auth_type: "browser" as const, + xoxc_token: "xoxc-file", + xoxd_cookie: "__KEYCHAIN__", + }, + }); + const keychain = new Map([ + ["xoxd", "xoxd-unscoped-legacy"], + ["xoxd:https://team.slack.com", "xoxd-commercial"], + ["xoxd:https://agency.slack-gov.com", "xoxd-gov"], + ]); + + try { + await writeFile( + credentialsFile, + JSON.stringify({ + version: 1, + workspaces: [ + browserWorkspace("https://team.slack.com"), + browserWorkspace("https://agency.slack-gov.com"), + browserWorkspace("https://legacy-only.slack.com"), + ], + }), + ); + + const credentials = await loadCredentials({ + credentialsFile, + keychainRead: (account) => keychain.get(account) ?? null, + }); + + expect( + credentials.workspaces.map((workspace) => + workspace.auth.auth_type === "browser" ? workspace.auth.xoxd_cookie : null, + ), + ).toEqual(["xoxd-commercial", "xoxd-gov", "__KEYCHAIN__"]); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("refuses malformed stored credentials without overwriting them", async () => { + const dir = await mkdtemp(join(tmpdir(), "agent-slack-credentials-")); + const credentialsFile = join(dir, "credentials.json"); + try { + await writeFile(credentialsFile, "{not-json"); + await expect(readStoredCredentials(credentialsFile)).rejects.toThrow( + "refusing to use or overwrite", + ); + expect(await readFile(credentialsFile, "utf8")).toBe("{not-json"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +});