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",