From 03eb864c9daa801b88b795b68dec78e4a00214b5 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 22 Oct 2026 15:46:39 -0700 Subject: [PATCH] Keep machines and sign-ins working when a deployment changes its origin Stored machine GitHub authorizations and browser session credentials are encrypted with APP_ORIGIN as additional data, so changing it made every machine run `chopin pair` again and signed everyone out. The optional PREVIOUS_ORIGINS setting lists the origins a deployment moved from. A credential that only decrypts under one of them is re-encrypted under APP_ORIGIN as its next credential revision, through the existing compare-and-swap rotation, so concurrent moves are safe and later restarts no longer need the old origin. Origins outside APP_ORIGIN and PREVIOUS_ORIGINS are still refused. Origin and WebSocket checks also accept the previous origins, and MCP document links on them resolve to the same documents, while Chopin keeps writing links on APP_ORIGIN. Self-hosting documents how to move. Assistant-model: Claude Opus 5.5 (fast) Assistant-workflow: inline (fast subagents) Assistant-verification: bun test (6733 pass), bun run types, bun run ci Assistant-verification: PostgreSQL storage tests (121 pass) Assistant-verification: e2e auth, machines, connector, build restart, planner picker, experiments, machine choice (57 pass) Co-authored-by: Alex Lavaee --- .env.example | 3 + apps/server/src/auth/config.ts | 34 +++++++++-- apps/server/src/auth/machines.test.ts | 56 ++++++++++++++++- apps/server/src/auth/machines.ts | 61 +++++++++++++++---- apps/server/src/auth/routes.test.ts | 40 ++++++++++++ apps/server/src/auth/routes.ts | 5 +- .../src/auth/session-persistence.test.ts | 47 ++++++++++++++ apps/server/src/auth/session.ts | 56 +++++++++++++---- apps/server/src/channels/routes.ts | 15 ++--- apps/server/src/config.test.ts | 20 ++++++ apps/server/src/experiments/routes.ts | 7 ++- apps/server/src/harness/remote/endpoint.ts | 3 +- apps/server/src/mcp/hosted.test.ts | 40 ++++++++++++ apps/server/src/mcp/hosted.ts | 10 +-- apps/server/src/mcp/routes.test.ts | 16 +++++ apps/server/src/mcp/routes.ts | 3 +- apps/server/src/navigation/routes.ts | 5 +- apps/server/src/research/routes.ts | 3 +- apps/server/src/socket/admission.test.ts | 10 +++ apps/server/src/socket/admission.ts | 5 +- apps/server/src/tasks/routes.ts | 11 ++-- apps/server/src/testing/config.ts | 1 + compose.yaml | 1 + docs/authentication.md | 10 ++- docs/self-hosting.md | 33 +++++++++- scripts/compose.test.ts | 1 + 26 files changed, 438 insertions(+), 58 deletions(-) diff --git a/.env.example b/.env.example index 7389068f3..31f19bae1 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,9 @@ DECISIONS_TIMEOUT_MS=30000 # GitHub App sign-in. # Remote VM mode overrides this local origin in child processes; see docs/exe-dev.md. APP_ORIGIN=http://127.0.0.1:8787 +# Optional comma-separated origins this deployment moved from, so paired machines +# and logins stored under them keep working; see docs/self-hosting.md. +PREVIOUS_ORIGINS= GITHUB_APP_SLUG= GITHUB_APP_CLIENT_ID= GITHUB_APP_CLIENT_SECRET= diff --git a/apps/server/src/auth/config.ts b/apps/server/src/auth/config.ts index 1d55443dd..55a9e2267 100644 --- a/apps/server/src/auth/config.ts +++ b/apps/server/src/auth/config.ts @@ -1,6 +1,8 @@ import { createHash } from "node:crypto"; export type AuthConfig = { origin: string; + /** Origins the deployment was served from before APP_ORIGIN (PREVIOUS_ORIGINS). */ + previousOrigins?: readonly string[]; appSlug: string; clientId: string; clientSecret?: string; @@ -14,13 +16,13 @@ export type AuthConfig = { const GITHUB_USER_LOGIN = /^[a-z0-9](?:[a-z0-9_-]{0,37}[a-z0-9_])?$/i; const GITHUB_ORGANIZATION_LOGIN = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/i; -function origin(raw: string | undefined): string { - if (!raw) throw new Error("APP_ORIGIN is required"); +function origin(raw: string | undefined, name = "APP_ORIGIN"): string { + if (!raw) throw new Error(`${name} is required`); let parsed: URL; try { parsed = new URL(raw); } catch { - throw new Error("APP_ORIGIN must be an HTTP or HTTPS origin"); + throw new Error(`${name} must be an HTTP or HTTPS origin`); } if ( parsed.origin !== raw @@ -30,17 +32,37 @@ function origin(raw: string | undefined): string { || parsed.pathname !== "/" || parsed.search || parsed.hash - ) throw new Error("APP_ORIGIN must contain only an HTTP or HTTPS origin"); + ) throw new Error(`${name} must contain only an HTTP or HTTPS origin`); let loopback = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]"; if (parsed.protocol !== "https:" && !loopback) { - throw new Error("APP_ORIGIN must use HTTPS unless it is loopback development"); + throw new Error(`${name} must use HTTPS unless it is loopback development`); } return parsed.origin; } +/** The origins a moved deployment was served from, other than its current one. */ +function previous(raw: string | undefined, current: string): string[] | undefined { + if (!raw?.trim()) return undefined; + let values = raw.split(",").map(value => value.trim()); + if (values.some(value => !value)) { + throw new Error("PREVIOUS_ORIGINS must be a comma-separated list of origins"); + } + let origins = [...new Set(values.map(value => origin(value, "PREVIOUS_ORIGINS")))] + .filter(value => value !== current); + return origins.length ? origins : undefined; +} + +/** Whether `value` is the deployment's origin or one of the origins it moved from. */ +export function ownOrigin( + config: Pick, + value: string | null | undefined, +): boolean { + return !!value && (value === config.origin || !!config.previousOrigins?.includes(value)); +} + function encryptionKey(raw: string | undefined): Uint8Array { if (!raw || !/^[0-9a-f]{64}$/i.test(raw)) { throw new Error("SESSION_ENCRYPTION_KEY must be exactly 32 bytes encoded as 64 hex characters"); @@ -88,8 +110,10 @@ export function loadAuth(port = 8787, host = process.env.SERVER_HOST || "127.0.0 if (demo === "on" && mode !== "local") { throw new Error("DEMO_PERSONAS=on requires AUTH_MODE=local on a loopback bind"); } + let previousOrigins = previous(process.env.PREVIOUS_ORIGINS, appOrigin); return { origin: appOrigin, + ...(previousOrigins ? { previousOrigins } : {}), appSlug, clientId, ...(clientSecret ? { clientSecret } : {}), diff --git a/apps/server/src/auth/machines.test.ts b/apps/server/src/auth/machines.test.ts index d02ff0f8d..17a78404f 100644 --- a/apps/server/src/auth/machines.test.ts +++ b/apps/server/src/auth/machines.test.ts @@ -33,10 +33,13 @@ async function setup() { }, }; let admitted = true; - let create = (refresh = (token: string) => github.refresh(token)) => + let create = ( + refresh = (token: string) => github.refresh(token), + scope: { origin: string; previousOrigins?: string[] } = { origin: "https://chopin.test" }, + ) => new Machines(storage, () => now, { key: KEY, - origin: "https://chopin.test", + ...scope, clientId: "client", refresh, authorize: async () => admitted, @@ -61,6 +64,10 @@ async function setup() { }; } +function unexpectedRefresh(token: string): never { + throw new Error(`no refresh expected for ${token}`); +} + async function failure(promise: Promise) { let error = await promise.then(() => undefined, err => err); expect(error).toBeInstanceOf(MachineError); @@ -191,3 +198,48 @@ test("a refresh refused because another instance rotated first uses that instanc expect(stored.reauthorizeAt).toBeUndefined(); expect(spent).toEqual(new Set(["ghr_machine_1"])); }); + +test("a machine paired before the deployment moved keeps working and is re-encrypted under the new origin", async () => { + let { storage, create, paired, refreshed } = await setup(); + let logged: unknown[][] = []; + let info = console.info; + console.info = (...values: unknown[]) => logged.push(values); + try { + // Restart after APP_ORIGIN moved to a new name, listing the old one as previous. + let moved = create(unexpectedRefresh, { + origin: "https://chopin.example.com", + previousOrigins: ["https://chopin.test"], + }); + let machine = await moved.authenticate(paired.token); + let [first, second] = await Promise.all([ + moved.use(machine, async token => token), + create(unexpectedRefresh, { + origin: "https://chopin.example.com", + previousOrigins: ["https://chopin.test"], + }).use(machine, async token => token), + ]); + expect([first, second]).toEqual(["ghu_machine_1", "ghu_machine_1"]); + } finally { + console.info = info; + } + expect(refreshed).toEqual([]); + expect((await storage.machines.get(paired.machine.id))!.credentials?.revision).toBe(2); + expect(logged.length).toBeGreaterThan(0); + expect(JSON.stringify(logged)).not.toContain("ghu_machine_1"); + + // Nothing depends on the old origin any more. + let later = create(unexpectedRefresh, { origin: "https://chopin.example.com" }); + expect(await later.use(await later.authenticate(paired.token), async token => token)) + .toBe("ghu_machine_1"); +}); + +test("a machine whose grant was stored under an origin not listed as previous is refused", async () => { + let { storage, create, paired } = await setup(); + let moved = create(undefined, { + origin: "https://chopin.example.com", + previousOrigins: ["https://old.example.com"], + }); + let machine = await moved.authenticate(paired.token); + expect(await failure(moved.use(machine, async token => token))).toBe("machine-reauthorize"); + expect((await storage.machines.get(paired.machine.id))!.credentials?.revision).toBe(1); +}); diff --git a/apps/server/src/auth/machines.ts b/apps/server/src/auth/machines.ts index b5492ebaf..bd5cc0b10 100644 --- a/apps/server/src/auth/machines.ts +++ b/apps/server/src/auth/machines.ts @@ -36,6 +36,8 @@ type Options = { /** The deployment's SESSION_ENCRYPTION_KEY; without it machines hold no GitHub credentials. */ key?: Uint8Array; origin: string; + /** Origins the deployment moved from; a grant stored under one is moved to `origin` on use. */ + previousOrigins?: readonly string[]; clientId: string; refresh?: (refreshToken: string) => Promise; /** Whether the owner is still admitted, as a browser session is rechecked. */ @@ -69,6 +71,7 @@ export class Machines { readonly #refreshes = new Map>(); readonly #seen = new Map(); readonly #ended = new Set<(machineId: string) => void>(); + #migrated = false; constructor(storage: StorageAdapter, clock: Clock, options: Options) { this.#storage = storage; @@ -222,26 +225,62 @@ export class Machines { if (cached) return cached; let stored = await this.current(machine.id); if (!stored.credentials || !this.#key) throw new MachineError("machine-reauthorize"); + let { revision } = stored.credentials; let value: unknown; - try { - value = await decrypted( - await this.#key, - this.#aad(stored, stored.credentials.revision), - stored.credentials.ciphertext, - ); - } catch { - throw new MachineError("machine-reauthorize"); + let moved = false; + for (let origin of [this.#options.origin, ...this.#options.previousOrigins ?? []]) { + try { + value = await decrypted( + await this.#key, + this.#aad(stored, revision, origin), + stored.credentials.ciphertext, + ); + moved = origin !== this.#options.origin; + break; + } catch { + // Stored under another origin, or not at all. + } } let grant = value as Partial | undefined; if ( typeof grant?.accessToken !== "string" || typeof grant.refreshToken !== "string" || typeof grant.accessExpiresAt !== "number" || typeof grant.refreshExpiresAt !== "number" ) throw new MachineError("machine-reauthorize"); - let restored = { ...grant, revision: stored.credentials.revision } as Grant; + let restored = { ...grant, revision } as Grant; + if (moved) return this.#move(stored, restored); this.#grants.set(machine.id, restored); return restored; } + /** + * Store a grant read under a previous origin again under the current one, as the next revision + * so that only one concurrent move wins; a loser reads whatever the winner stored. + */ + async #move(machine: StoredMachine, grant: Grant): Promise { + let next = { ...grant, revision: grant.revision + 1 }; + if ( + await this.#storage.machines.rotate( + machine.id, + grant.revision, + await this.#encrypt(machine, next), + ) + ) { + if (!this.#migrated) { + this.#migrated = true; + console.info("[machines] moved a machine's stored GitHub authorization to APP_ORIGIN"); + } + this.#grants.set(machine.id, next); + return next; + } + let current = this.#grants.get(machine.id); + if (current) return current; + let stored = await this.current(machine.id); + if (stored.credentials?.revision === grant.revision) { + throw new MachineError("machine-reauthorize"); + } + return this.#grant(stored); + } + #rotate(machine: StoredMachine, from: Grant): Promise { let active = this.#refreshes.get(machine.id); if (active) return active; @@ -332,10 +371,10 @@ export class Machines { }; } - #aad(machine: StoredMachine, revision: number): string { + #aad(machine: StoredMachine, revision: number, origin = this.#options.origin): string { return JSON.stringify([ MACHINE_CONTEXT, - this.#options.origin, + origin, this.#options.clientId, machine.id, machine.userId, diff --git a/apps/server/src/auth/routes.test.ts b/apps/server/src/auth/routes.test.ts index ecd242c24..16001874d 100644 --- a/apps/server/src/auth/routes.test.ts +++ b/apps/server/src/auth/routes.test.ts @@ -235,6 +235,46 @@ describe("hosted authentication routes", () => { } }); + it("keeps logins and machines from before APP_ORIGIN moved when the old origin is listed", async () => { + let storage = new MemoryStorage(); + let github = new FakeGitHub(); + let router = new Router(); + let auth = registerAuthRoutes(router, { config: CONFIG, storage, github }); + let cookie = pair( + cookies(await callback(router)).find(value => value.startsWith("__Host-chopin_session="))!, + ); + let paired = await auth.machines.pair({ + userId: "U_octocat", + label: "laptop", + repositories: [], + autoRun: false, + grant: { + accessToken: "ghu_machine", + accessExpiresIn: 28_800, + refreshToken: "ghr_machine", + refreshExpiresIn: 15_897_600, + }, + }); + let restart = async (config: AuthConfig) => { + let restarted = new Router(); + let { machines } = registerAuthRoutes(restarted, { config, storage, github }); + let response = (await restarted.handle( + new Request(`${config.origin}/api/session`, { headers: { cookie } }), + ))!; + let machine = await machines.authenticate(paired.token); + return { + login: ((await response.json()) as { user: { login: string } | null }).user?.login, + token: await machines.use(machine, async token => token).catch(() => undefined), + }; + }; + let moved = { ...CONFIG, origin: "https://chopin.example.com" }; + expect(await restart({ ...moved, previousOrigins: ["https://old.example.com"] })) + .toEqual({ login: undefined, token: undefined }); + expect(await restart({ ...moved, previousOrigins: [CONFIG.origin] })) + .toEqual({ login: "octocat", token: "ghu_machine" }); + expect(await restart(moved)).toEqual({ login: "octocat", token: "ghu_machine" }); + }); + it("signs in, reports the session, lists repositories and logs out", async () => { let now = new Date("2026-08-13T12:00:00.000Z"); let storage = new MemoryStorage(); diff --git a/apps/server/src/auth/routes.ts b/apps/server/src/auth/routes.ts index e3ca4e593..dfc677b4f 100644 --- a/apps/server/src/auth/routes.ts +++ b/apps/server/src/auth/routes.ts @@ -2,6 +2,7 @@ import { GitHubClient, GitHubError } from "../github/client"; import { StorageError } from "../storage/errors"; import { demoRoster } from "../demo/roster"; import { Admission, AdmissionDenied } from "./admission"; +import { ownOrigin } from "./config"; import { OAuthAttempts, Sessions } from "./session"; import { Machines } from "./machines"; import { DeviceAuthorization } from "./device"; @@ -182,6 +183,7 @@ export function registerAuthRoutes( persistence: config.local ? undefined : { key: config.encryptionKey, origin: config.origin, + previousOrigins: config.previousOrigins, clientId: config.clientId, }, refresh: refreshToken => @@ -235,6 +237,7 @@ export function registerAuthRoutes( let machines = new Machines(storage, clock, { key: config.local ? undefined : config.encryptionKey, origin: config.origin, + previousOrigins: config.previousOrigins, clientId: config.clientId, refresh, authorize: async (userId, token) => { @@ -533,7 +536,7 @@ export function registerAuthRoutes( ); router.on("POST", "/auth/logout", async request => { - if (request.headers.get("origin") !== config.origin) { + if (!ownOrigin(config, request.headers.get("origin"))) { return json({ error: "origin is not allowed" }, 403); } try { diff --git a/apps/server/src/auth/session-persistence.test.ts b/apps/server/src/auth/session-persistence.test.ts index 807180d51..b2eedf707 100644 --- a/apps/server/src/auth/session-persistence.test.ts +++ b/apps/server/src/auth/session-persistence.test.ts @@ -83,6 +83,53 @@ describe("encrypted hosted sessions", () => { } }); + it("restores a session saved before the deployment moved and re-encrypts it under the new origin", async () => { + let { storage, issued, request, clock } = await fixture(); + let moved = { + ...persistence, + origin: "https://chopin.example.com", + previousOrigins: ["https://chopin.test"], + }; + let logged: unknown[][] = []; + let info = console.info; + console.info = (...values: unknown[]) => logged.push(values); + let restored; + try { + // Two processes restore the same browser at once; one move wins, both sign in. + restored = await Promise.all([ + new Sessions(storage, true, clock, { persistence: moved }).authenticate(request), + new Sessions(storage, true, clock, { persistence: moved }).authenticate(request), + ]); + } finally { + console.info = info; + } + for (let result of restored) expect(result?.access.token).toBe(grant.accessToken); + expect((await storage.sessions.get(issued.id, clock()))!.credentials!.revision).toBe(2); + expect(logged.length).toBeGreaterThan(0); + expect(JSON.stringify(logged)).not.toContain(grant.accessToken); + + let { previousOrigins: _, ...settled } = moved; + let later = new Sessions(storage, true, clock, { persistence: settled }); + expect((await later.authenticate(request))?.access.token).toBe(grant.accessToken); + let unlisted = new Sessions(storage, true, clock, { + persistence: { ...persistence, origin: "https://other.example.com" }, + }); + expect(await unlisted.authenticate(request)).toBeUndefined(); + }); + + it("refuses a session saved under an origin that is not listed as previous", async () => { + let { storage, issued, request, clock } = await fixture(); + let restarted = new Sessions(storage, true, clock, { + persistence: { + ...persistence, + origin: "https://chopin.example.com", + previousOrigins: ["https://old.example.com"], + }, + }); + expect(await restarted.authenticate(request)).toBeUndefined(); + expect((await storage.sessions.get(issued.id, clock()))!.credentials!.revision).toBe(1); + }); + it("authenticates the ciphertext, identity, lifetime, verifier and revision together", async () => { let { storage, issued, request, clock } = await fixture(); let saved = (await storage.sessions.get(issued.id, clock()))!; diff --git a/apps/server/src/auth/session.ts b/apps/server/src/auth/session.ts index b235a8e60..81b64437a 100644 --- a/apps/server/src/auth/session.ts +++ b/apps/server/src/auth/session.ts @@ -78,7 +78,13 @@ type MemorySession = { export type DelegatedSource = (rejected?: string) => Promise<{ token: string; expiresAt: Date }>; type SessionOptions = { - persistence?: { key: Uint8Array; origin: string; clientId: string }; + persistence?: { + key: Uint8Array; + origin: string; + /** Origins the deployment moved from; a session stored under one is moved to `origin`. */ + previousOrigins?: readonly string[]; + clientId: string; + }; refresh?: (refreshToken: string) => Promise; authorize?: (user: UserRecord, accessToken: string) => Promise; beforeRefresh?: (sessionId: string, revision: number) => Promise; @@ -233,6 +239,7 @@ export class Sessions { readonly #refreshes = new Map>(); readonly #revocations = new Map>(); readonly #pendingDeletes = new Set(); + #migrated = false; readonly cookieName: string; constructor( @@ -333,11 +340,16 @@ export class Sessions { return this.#resolve(current); } - #aad(session: WebSession, secretHash: Uint8Array, revision: number): string { + #aad( + session: WebSession, + secretHash: Uint8Array, + revision: number, + origin = this.#options.persistence!.origin, + ): string { let scope = this.#options.persistence!; return JSON.stringify([ SESSION_CONTEXT, - scope.origin, + origin, scope.clientId, session.id, session.userId, @@ -382,19 +394,25 @@ export class Sessions { } } - async #restore(id: string, proof: Uint8Array): Promise { + async #restore(id: string, proof: Uint8Array, retry = true): Promise { let stored = await this.#storage.sessions.get(id, this.#clock()); if (!stored?.credentials || !equal(proof, stored.credentials.secretHash)) return undefined; let { credentials, ...session } = stored; + let scope = this.#options.persistence!; let payload: unknown; - try { - payload = await decrypted( - await this.#key!, - this.#aad(session, proof, credentials.revision), - credentials.ciphertext, - ); - } catch { - return undefined; + let moved = false; + for (let origin of [scope.origin, ...scope.previousOrigins ?? []]) { + try { + payload = await decrypted( + await this.#key!, + this.#aad(session, proof, credentials.revision, origin), + credentials.ciphertext, + ); + moved = origin !== scope.origin; + break; + } catch { + // Stored under another origin, or not at all. + } } if (!payload || typeof payload !== "object") return undefined; let value = payload as Record; @@ -418,6 +436,20 @@ export class Sessions { refreshExpiresAt: new Date(value.refreshExpiresAt), revision: credentials.revision, }; + if (moved) { + // Stored again under the current origin as the next revision: one concurrent move wins, + // and a loser reads whatever the winner stored. + let next = { ...current, revision: current.revision + 1 }; + if (!await this.#storage.sessions.rotate(id, current.revision, await this.#encrypt(next))) { + return retry ? this.#restore(id, proof, false) : undefined; + } + if (!this.#migrated) { + this.#migrated = true; + console.info("[sessions] moved a browser session's stored credentials to APP_ORIGIN"); + } + if (this.#pendingDeletes.has(id) || this.#revocations.has(id)) return undefined; + current = next; + } this.#sessions.set(id, current); return current; } diff --git a/apps/server/src/channels/routes.ts b/apps/server/src/channels/routes.ts index 7de513704..9f3adb297 100644 --- a/apps/server/src/channels/routes.ts +++ b/apps/server/src/channels/routes.ts @@ -3,6 +3,7 @@ import * as Plan from "../plan/service"; import { documentPath } from "@chopin/protocol/document-url"; import { MAX_GITHUB_REFERENCES, parseGitHubReferenceKey } from "@chopin/protocol/github-reference"; +import { ownOrigin } from "../auth/config"; import { GitHubError } from "../github/client"; import { StorageError } from "../storage/errors"; @@ -340,7 +341,7 @@ export function registerChannelRoutes( "POST", "/api/repositories/:owner/:repository/channels", async (request, _url, params) => { - if (request.headers.get("origin") !== auth.config.origin) { + if (!ownOrigin(auth.config, request.headers.get("origin"))) { return json({ error: "origin is not allowed" }, 403); } try { @@ -534,7 +535,7 @@ export function registerChannelRoutes( "POST", "/api/channels/:channelId/version-summaries", async (request, _url, params) => { - if (request.headers.get("origin") !== auth.config.origin) { + if (!ownOrigin(auth.config, request.headers.get("origin"))) { return json({ error: "origin is not allowed" }, 403); } try { @@ -556,7 +557,7 @@ export function registerChannelRoutes( "POST", "/api/channels/:channelId/versions/:versionId/restore", async (request, _url, params) => { - if (request.headers.get("origin") !== auth.config.origin) { + if (!ownOrigin(auth.config, request.headers.get("origin"))) { return json({ error: "origin is not allowed" }, 403); } try { @@ -638,7 +639,7 @@ export function registerChannelRoutes( } router.on("PATCH", "/api/channels/:channelId", async (request, _url, params) => { - if (request.headers.get("origin") !== auth.config.origin) { + if (!ownOrigin(auth.config, request.headers.get("origin"))) { return json({ error: "origin is not allowed" }, 403); } try { @@ -691,7 +692,7 @@ export function registerChannelRoutes( action: "archive" | "restore", ) => { router.on("POST", path, async (request, _url, params) => { - if (request.headers.get("origin") !== auth.config.origin) { + if (!ownOrigin(auth.config, request.headers.get("origin"))) { return json({ error: "origin is not allowed" }, 403); } try { @@ -734,7 +735,7 @@ export function registerChannelRoutes( registerArchiveTransition("/api/channels/:channelId/restore", "restore"); router.on("DELETE", "/api/channels/:channelId", async (request, _url, params) => { - if (request.headers.get("origin") !== auth.config.origin) { + if (!ownOrigin(auth.config, request.headers.get("origin"))) { return json({ error: "origin is not allowed" }, 403); } try { @@ -771,7 +772,7 @@ export function registerChannelRoutes( }); router.on("POST", "/api/channels/:channelId/agent/reset", async (request, _url, params) => { - if (request.headers.get("origin") !== auth.config.origin) { + if (!ownOrigin(auth.config, request.headers.get("origin"))) { return json({ error: "origin is not allowed" }, 403); } try { diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index 57f06d606..f77c88d24 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -241,6 +241,26 @@ describe("configuration", () => { expect(() => configured({ SESSION_ENCRYPTION_KEY: "short" })).toThrow("32 bytes"); }); + it("accepts the exact origins a moved deployment was served from", () => { + expect(configured().auth.previousOrigins).toBeUndefined(); + expect(configured({ PREVIOUS_ORIGINS: " " }).auth.previousOrigins).toBeUndefined(); + expect( + configured({ + PREVIOUS_ORIGINS: + "https://old.example.com, https://older.example.com,https://chopin.example", + }).auth.previousOrigins, + ).toEqual(["https://old.example.com", "https://older.example.com"]); + expect(() => configured({ PREVIOUS_ORIGINS: "http://old.example.com" })).toThrow( + "PREVIOUS_ORIGINS must use HTTPS", + ); + expect(() => configured({ PREVIOUS_ORIGINS: "https://old.example.com/path" })).toThrow( + "PREVIOUS_ORIGINS must contain only an HTTP or HTTPS origin", + ); + expect(() => configured({ PREVIOUS_ORIGINS: "https://old.example.com,," })).toThrow( + "PREVIOUS_ORIGINS must be a comma-separated list of origins", + ); + }); + it("loads normalized user and organization admission lists", () => { let config = configured({ GITHUB_ALLOWED_USERS: " OctoCat,hubot,octocat,managed_user ", diff --git a/apps/server/src/experiments/routes.ts b/apps/server/src/experiments/routes.ts index f135f6f40..1bb1b2e05 100644 --- a/apps/server/src/experiments/routes.ts +++ b/apps/server/src/experiments/routes.ts @@ -4,6 +4,7 @@ import { publicInvestigation } from "@chopin/experiment/records"; import type { Investigation, InvestigationProvenance } from "@chopin/experiment/records"; import type { HostedAuth } from "../auth/routes"; import { AdmissionDenied } from "../auth/admission"; +import { ownOrigin } from "../auth/config"; import { MachineError } from "../auth/machines"; import type { GitHubTokenGrant } from "../github/client"; import type { ChannelRecord, StoredMachine } from "../storage/model"; @@ -692,12 +693,14 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio ) { router.on(method, path, async (request, _url, params) => { try { - if (request.headers.has("origin") && request.headers.get("origin") !== auth.config.origin) { + if ( + request.headers.has("origin") && !ownOrigin(auth.config, request.headers.get("origin")) + ) { fail("origin-forbidden"); } if ( method !== "GET" && request.headers.has("cookie") - && request.headers.get("origin") !== auth.config.origin + && !ownOrigin(auth.config, request.headers.get("origin")) ) fail("origin-forbidden"); return await action(request, params); } catch (error) { diff --git a/apps/server/src/harness/remote/endpoint.ts b/apps/server/src/harness/remote/endpoint.ts index 87c8ae803..5242868c7 100644 --- a/apps/server/src/harness/remote/endpoint.ts +++ b/apps/server/src/harness/remote/endpoint.ts @@ -22,6 +22,7 @@ import { } from "@chopin/agent-link"; import { AdmissionDenied } from "../../auth/admission"; +import { ownOrigin } from "../../auth/config"; import { documentUrl } from "../../channels/document-url"; import { GitHubError } from "../../github/client"; import { @@ -150,7 +151,7 @@ export async function admitAgent( if (!enabled) return refusal(404, "This Chopin does not accept agent connections."); if (!offersSubprotocol(request)) return subprotocolRequired(); let origin = request.headers.get("origin"); - if (origin !== null && origin !== auth.config.origin) { + if (origin !== null && !ownOrigin(auth.config, origin)) { return refusal(403, "origin is not allowed"); } let token = request.headers.get("authorization")?.match(BEARER)?.[1]; diff --git a/apps/server/src/mcp/hosted.test.ts b/apps/server/src/mcp/hosted.test.ts index 4485f9cc9..da68dc38b 100644 --- a/apps/server/src/mcp/hosted.test.ts +++ b/apps/server/src/mcp/hosted.test.ts @@ -1014,6 +1014,46 @@ describe("the hosted MCP adapter", () => { } }); + it("reads documents by their links on an origin the deployment moved from", async () => { + let context = setup(); + context.auth.config = { + ...context.auth.config, + origin: "https://chopin.example.com", + previousOrigins: ["https://chopin.test"], + }; + let opened = await plan(context); + let child = await context.storage.channels.create({ + id: crypto.randomUUID(), + repositoryId: "R_score", + repositoryOwner: "octo-org", + repositoryName: "score", + title: "Rollback research", + createdBy: "U_allowed", + parentChannelId: opened.channel.id, + now: context.now, + }); + let adapter = hosted(context.auth); + let caller = await adapter.caller(request("Bearer allowed")); + if (!caller) throw new Error("test caller was not authenticated"); + let url = "/documents/octo-org/score/release-readiness"; + let childUrl = `${url}/children/rollback-research`; + + try { + for (let origin of ["https://chopin.example.com", "https://chopin.test"]) { + expect(await adapter.documents.read(caller, `${origin}${url}`)) + .toMatchObject({ id: opened.channel.id, url }); + expect(await adapter.documents.read(caller, `${origin}${childUrl}`)) + .toMatchObject({ id: child.id, url: childUrl }); + expect(await adapter.documents.read(caller, `${origin}/channels/${opened.channel.id}`)) + .toMatchObject({ id: opened.channel.id, url }); + } + expect(await adapter.documents.read(caller, `https://other.example.com${url}`)) + .toBeUndefined(); + } finally { + await Service.close(opened.plan); + } + }); + it("reads an open plan from its authoritative live document", async () => { let context = setup(); let opened = await plan(context); diff --git a/apps/server/src/mcp/hosted.ts b/apps/server/src/mcp/hosted.ts index 3edd73a53..5c3a949bc 100644 --- a/apps/server/src/mcp/hosted.ts +++ b/apps/server/src/mcp/hosted.ts @@ -7,6 +7,7 @@ import { parseDocumentPath, } from "@chopin/protocol/document-url"; +import { ownOrigin } from "../auth/config"; import { documentUrl } from "../channels/document-url"; import { notAttachedMessage } from "../harness/remote/attachments"; import { deterministicChannelId, isChannelId } from "../channels/id"; @@ -193,7 +194,8 @@ export async function locateDocument(auth: LocatorAuth, caller: HostedCaller, lo } catch { path = undefined; } - let local = path?.origin === auth.config.origin ? path.pathname : undefined; + // A link from before the deployment moved names the same document. + let local = ownOrigin(auth.config, path?.origin) ? path!.pathname : undefined; let child = local === undefined ? undefined : parseChildDocumentPath(local); if (child) { let repository = await directRepositoryFor(auth, caller, child.owner, child.repository); @@ -220,9 +222,9 @@ export async function locateDocument(auth: LocatorAuth, caller: HostedCaller, lo return channel ? { channel, repository } : undefined; } - let legacy = path?.origin === auth.config.origin - ? /^\/channels\/([0-9a-f-]{36})\/?$/i.exec(path.pathname) - : undefined; + let legacy = local === undefined + ? undefined + : /^\/channels\/([0-9a-f-]{36})\/?$/i.exec(local); let id = legacy?.[1]?.toLowerCase(); let channel = await auth.storage.channels.get(id && isChannelId(id) ? id : locator); if (!channel) return undefined; diff --git a/apps/server/src/mcp/routes.test.ts b/apps/server/src/mcp/routes.test.ts index 5ec05ce7f..7390bd192 100644 --- a/apps/server/src/mcp/routes.test.ts +++ b/apps/server/src/mcp/routes.test.ts @@ -178,6 +178,22 @@ describe("the hosted MCP route", () => { } }); + it("also accepts an origin the deployment moved from", async () => { + let { router } = setup({ previousOrigins: ["https://old.example.com"] }); + for ( + let [origin, status] of [["https://old.example.com", 200], [ + "https://other.example.com", + 403, + ]] as const + ) { + let response = await router.handle(request("POST", { + headers: { "content-type": "application/json", origin }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }), + })); + expect(response?.status).toBe(status); + } + }); + it("does not disclose hosted failures through MCP", async () => { let { github, router } = setup(); github.failure = new Error("access-token foreign document: Private roadmap"); diff --git a/apps/server/src/mcp/routes.ts b/apps/server/src/mcp/routes.ts index 5b75ff366..765381e2f 100644 --- a/apps/server/src/mcp/routes.ts +++ b/apps/server/src/mcp/routes.ts @@ -1,5 +1,6 @@ import { handler } from "../mcp"; import { AdmissionDenied } from "../auth/admission"; +import { ownOrigin } from "../auth/config"; import { GitHubError } from "../github/client"; import { hosted } from "./hosted"; @@ -38,7 +39,7 @@ export function registerMcpRoutes( ): void { let endpoint = handler(hosted(auth, persistence, callbacks)); let route = async (request: Request): Promise => { - if (request.headers.has("origin") && request.headers.get("origin") !== auth.config.origin) { + if (request.headers.has("origin") && !ownOrigin(auth.config, request.headers.get("origin"))) { return protectedResponse(new Response("origin is not allowed", { status: 403 })); } try { diff --git a/apps/server/src/navigation/routes.ts b/apps/server/src/navigation/routes.ts index a6e1db6eb..c20a106fd 100644 --- a/apps/server/src/navigation/routes.ts +++ b/apps/server/src/navigation/routes.ts @@ -1,3 +1,4 @@ +import { ownOrigin } from "../auth/config"; import { GitHubError } from "../github/client"; import { StorageError } from "../storage/errors"; @@ -182,7 +183,7 @@ export function registerNavigationRoutes( }); router.on("POST", "/api/navigation/projects", async request => { - if (request.headers.get("origin") !== auth.config.origin) { + if (!ownOrigin(auth.config, request.headers.get("origin"))) { return json({ error: "origin is not allowed" }, 403); } try { @@ -218,7 +219,7 @@ export function registerNavigationRoutes( }); router.on("PATCH", "/api/navigation", async request => { - if (request.headers.get("origin") !== auth.config.origin) { + if (!ownOrigin(auth.config, request.headers.get("origin"))) { return json({ error: "origin is not allowed" }, 403); } try { diff --git a/apps/server/src/research/routes.ts b/apps/server/src/research/routes.ts index 407abba5a..a67b04ae5 100644 --- a/apps/server/src/research/routes.ts +++ b/apps/server/src/research/routes.ts @@ -1,3 +1,4 @@ +import { ownOrigin } from "../auth/config"; import { isChannelId } from "../channels/id"; import { GitHubError } from "../github/client"; import { JobServiceError } from "../jobs/service"; @@ -121,7 +122,7 @@ function canWrite(value: Repository): boolean { } function origin(request: Request, auth: HostedAuth): Response | undefined { - return request.headers.get("origin") === auth.config.origin + return ownOrigin(auth.config, request.headers.get("origin")) ? undefined : json({ error: "origin is not allowed" }, 403); } diff --git a/apps/server/src/socket/admission.test.ts b/apps/server/src/socket/admission.test.ts index 07268e228..4ee957b46 100644 --- a/apps/server/src/socket/admission.test.ts +++ b/apps/server/src/socket/admission.test.ts @@ -107,6 +107,7 @@ describe("socket admission", () => { clientId: "client", clientSecret: "secret", encryptionKey: new Uint8Array(32).fill(2), + previousOrigins: ["https://old.example.com"], }; let auth: HostedAuth = { config, @@ -128,6 +129,15 @@ describe("socket admission", () => { auth, ); expect(wrongOrigin).toEqual({ status: 403, reason: "origin is not allowed" }); + // A page still open on the name the deployment moved from keeps its socket. + let previousOrigin = await admit( + new Request(url, { + headers: { cookie: pair(issued.cookie), origin: "https://old.example.com" }, + }), + url, + auth, + ); + expect("data" in previousOrigin).toBe(true); let request = new Request(url, { headers: { cookie: pair(issued.cookie), origin: "https://chopin.test" }, }); diff --git a/apps/server/src/socket/admission.ts b/apps/server/src/socket/admission.ts index 7649843a3..3b4c30d2a 100644 --- a/apps/server/src/socket/admission.ts +++ b/apps/server/src/socket/admission.ts @@ -1,3 +1,4 @@ +import { ownOrigin } from "../auth/config"; import { isChannelId } from "../channels/id"; import { GitHubError } from "../github/client"; import { uid } from "../ids"; @@ -19,7 +20,7 @@ function probing(request: Request): boolean { export async function admitSidebar(request: Request, auth: HostedAuth): Promise { try { - if (!probing(request) && request.headers.get("origin") !== auth.config.origin) { + if (!probing(request) && !ownOrigin(auth.config, request.headers.get("origin"))) { return { status: 403, reason: "origin is not allowed" }; } let session = await auth.sessions.authenticate(request); @@ -54,7 +55,7 @@ export async function admit( } = {}, ): Promise { try { - if (!probing(request) && request.headers.get("origin") !== auth.config.origin) { + if (!probing(request) && !ownOrigin(auth.config, request.headers.get("origin"))) { return { status: 403, reason: "origin is not allowed" }; } let session = await auth.sessions.authenticate(request); diff --git a/apps/server/src/tasks/routes.ts b/apps/server/src/tasks/routes.ts index f71d5eefc..e8e3615aa 100644 --- a/apps/server/src/tasks/routes.ts +++ b/apps/server/src/tasks/routes.ts @@ -6,6 +6,7 @@ import { planningReadiness, reportImplementationLifecycle } from "./plan-graphs" import { exclusive } from "../plan/service"; import { GitHubError } from "../github/client"; import { AdmissionDenied } from "../auth/admission"; +import { ownOrigin } from "../auth/config"; import { implementationConnector } from "./connector"; import { checkoutOf } from "../experiments/connections"; import type { Connection, Connections } from "../experiments/connections"; @@ -276,7 +277,7 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o "POST", "/api/channels/:id/implementation", safe(async (request, _url, { id }) => { - if (request.headers.get("origin") !== auth.config.origin) { + if (!ownOrigin(auth.config, request.headers.get("origin"))) { throw new GitHubError("origin is not allowed", 403); } let { session, channel, repository } = await browser(request, id); @@ -350,7 +351,7 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o "POST", "/api/channels/:id/implementation/rebuild", safe(async (request, _url, { id }) => { - if (request.headers.get("origin") !== auth.config.origin) { + if (!ownOrigin(auth.config, request.headers.get("origin"))) { throw new GitHubError("origin is not allowed", 403); } let { session, channel, repository } = await browser(request, id); @@ -399,7 +400,7 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o "POST", "/api/channels/:id/implementation/stop", safe(async (request, _url, { id }) => { - if (request.headers.get("origin") !== auth.config.origin) { + if (!ownOrigin(auth.config, request.headers.get("origin"))) { throw new GitHubError("origin is not allowed", 403); } let { session, channel, repository } = await browser(request, id); @@ -442,7 +443,7 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o "DELETE", "/api/channels/:id/implementation/request", safe(async (request, _url, { id }) => { - if (request.headers.get("origin") !== auth.config.origin) { + if (!ownOrigin(auth.config, request.headers.get("origin"))) { throw new GitHubError("origin is not allowed", 403); } let { channel, repository } = await browser(request, id); @@ -457,7 +458,7 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o "POST", "/api/channels/:id/implementation/revise", safe(async (request, _url, { id }) => { - if (request.headers.get("origin") !== auth.config.origin) { + if (!ownOrigin(auth.config, request.headers.get("origin"))) { throw new GitHubError("origin is not allowed", 403); } let { channel, repository } = await browser(request, id); diff --git a/apps/server/src/testing/config.ts b/apps/server/src/testing/config.ts index 74bff50ee..0bddb142d 100644 --- a/apps/server/src/testing/config.ts +++ b/apps/server/src/testing/config.ts @@ -47,6 +47,7 @@ export function configured(overrides: Record = {}) { CHOPIN_LOCAL_CREDENTIALS_DIR: undefined, GITHUB_ALLOWED_USERS: undefined, GITHUB_ALLOWED_ORGANIZATIONS: undefined, + PREVIOUS_ORIGINS: undefined, CHOPIN_SETUP_AGENTS: undefined, ...overrides, }; diff --git a/compose.yaml b/compose.yaml index 19c33554f..31e97d97c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -14,6 +14,7 @@ services: OPENAI_API_KEY: ${OPENAI_API_KEY} JEV_API_KEY: ${JEV_API_KEY} APP_ORIGIN: ${APP_ORIGIN} + PREVIOUS_ORIGINS: ${PREVIOUS_ORIGINS} DATABASE_URL: postgresql://chopin:chopin@${SERVICE_NAME_DB:-db}:5432/chopin?sslmode=disable GITHUB_APP_SLUG: ${GITHUB_APP_SLUG} GITHUB_APP_CLIENT_ID: ${GITHUB_APP_CLIENT_ID} diff --git a/docs/authentication.md b/docs/authentication.md index 269807604..3a456015f 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -88,6 +88,13 @@ fragment, or trailing slash. HTTPS is required except for loopback development, such as `http://127.0.0.1:8787`. Callback URLs are built only from this value, never from incoming Host or forwarded headers. +`PREVIOUS_ORIGINS` optionally lists origins the deployment moved from. Hosted +session and machine credentials are encrypted with the origin as additional +data; one that only decrypts under a listed previous origin is re-encrypted +under `APP_ORIGIN` as its next credential revision, so later restarts no longer +need the old origin. See +[Moving to a new origin](self-hosting.md#moving-to-a-new-origin). + [Self-hosting](self-hosting.md) lists every variable and how to deploy. ## Local device-flow sign-in @@ -418,7 +425,8 @@ does not revoke the GitHub App authorization. OAuth state, the PKCE verifier, and the validated browser return path are held in a separate encrypted, ten-minute HttpOnly cookie. Browser state-changing routes -and WebSocket upgrades require an Origin header exactly equal to `APP_ORIGIN`. +and WebSocket upgrades require an Origin header exactly equal to `APP_ORIGIN` or +one of `PREVIOUS_ORIGINS`. The bearer-authenticated MCP route accepts a missing Origin, as non-browser clients normally omit it, but rejects a present mismatched Origin. Open sockets periodically recheck the process-local session, instance admission, and diff --git a/docs/self-hosting.md b/docs/self-hosting.md index f680bc9a2..a00d15edb 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -317,6 +317,7 @@ the image. | `STORAGE_DRIVER` | `postgres` | Storage adapter. `postgres` is currently the only accepted value. | | `DATABASE_URL` | required | `postgres:` or `postgresql:` connection URL. It is not printed by Chopin. | | `APP_ORIGIN` | required | Exact public origin, without credentials, path, query, fragment, or trailing slash. HTTPS is required unless the host is loopback. | +| `PREVIOUS_ORIGINS` | empty | Comma-separated origins this deployment was served from before `APP_ORIGIN`, each validated like `APP_ORIGIN`. Paired machines, browser logins, Origin checks, and document links from those origins keep working; see [Moving to a new origin](#moving-to-a-new-origin). | | `GITHUB_APP_SLUG` | required | Lowercase slug from the App's public URL. | | `GITHUB_APP_CLIENT_ID` | required | OAuth client ID, not the numeric GitHub App ID. | | `GITHUB_APP_CLIENT_SECRET` | required (hosted) | OAuth client secret used for user-token exchange and refresh. Unused when `AUTH_MODE=local`. | @@ -520,7 +521,8 @@ The proxy must: - proxy `/mcp` and the `/api/v1/agent` upgrade without removing their `Authorization` header, nor the upgrade's `Sec-WebSocket-Protocol` header; - serve Chopin at `/`, not below a path prefix; and -- redirect alternate hosts to the canonical origin before application traffic. +- redirect alternate hosts to the canonical origin before application traffic, + except the paths a [moved deployment](#moving-to-a-new-origin) keeps serving. Chopin derives OAuth callbacks from `APP_ORIGIN`, never from incoming `Host` or forwarded headers. A proxy cannot repair a mismatched configuration after the @@ -660,6 +662,35 @@ Because migrations are forward-only, returning to an older application image may require restoring the pre-upgrade database rather than merely changing the image tag. +### Moving to a new origin + +Paired machines' GitHub authorizations and browser logins are encrypted for the +origin they were stored under, so changing `APP_ORIGIN` alone signs everyone out +and makes every machine run `chopin pair` again. To move a deployment from +`https://old.example.com` to `https://new.example.com` instead: + +1. Add `https://new.example.com/auth/github/callback` to the GitHub App's + callback URLs, keeping the old one, and point its Homepage and Setup URLs at + the new origin. +2. Serve the new name, and keep serving the old one. On the old name, keep + proxying `/mcp`, `/api`, `/connector*`, `/auth/github/callback`, and + `/auth/github/setup` to Chopin, because paired machines, MCP clients, and + GitHub still use it; redirect browsers on other paths to the new name. +3. Restart with `APP_ORIGIN=https://new.example.com` and + `PREVIOUS_ORIGINS=https://old.example.com`. + +A machine or browser login stored under a previous origin is re-encrypted under +`APP_ORIGIN` the first time it is used, and the server logs once per kind that +it moved one. Browser cookies belong to a host, so people sign in once on the +new name. Origin and WebSocket checks accept the previous origins, and document +links on them resolve to the same documents; Chopin itself only writes links on +`APP_ORIGIN`. A sign-in that was in progress when the deployment restarted has +to start again. + +Remove `PREVIOUS_ORIGINS` once machines have reconnected and you stop serving +the old name. Never list an origin you no longer control: its pages would pass +Chopin's Origin checks. + ### Writer lease The database holds one renewable `chopin:writer` lease. A second application diff --git a/scripts/compose.test.ts b/scripts/compose.test.ts index cbf2b0531..e1bb5248f 100644 --- a/scripts/compose.test.ts +++ b/scripts/compose.test.ts @@ -9,6 +9,7 @@ const PREVIEW_CONFIGURATION = [ "OPENAI_API_KEY", "JEV_API_KEY", "APP_ORIGIN", + "PREVIOUS_ORIGINS", "GITHUB_APP_SLUG", "GITHUB_APP_CLIENT_ID", "GITHUB_APP_CLIENT_SECRET",