Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
34 changes: 29 additions & 5 deletions apps/server/src/auth/config.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand All @@ -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<AuthConfig, "origin" | "previousOrigins">,
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");
Expand Down Expand Up @@ -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 } : {}),
Expand Down
56 changes: 54 additions & 2 deletions apps/server/src/auth/machines.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<unknown>) {
let error = await promise.then(() => undefined, err => err);
expect(error).toBeInstanceOf(MachineError);
Expand Down Expand Up @@ -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);
});
61 changes: 50 additions & 11 deletions apps/server/src/auth/machines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GitHubTokenGrant>;
/** Whether the owner is still admitted, as a browser session is rechecked. */
Expand Down Expand Up @@ -69,6 +71,7 @@ export class Machines {
readonly #refreshes = new Map<string, Promise<Grant>>();
readonly #seen = new Map<string, number>();
readonly #ended = new Set<(machineId: string) => void>();
#migrated = false;

constructor(storage: StorageAdapter, clock: Clock, options: Options) {
this.#storage = storage;
Expand Down Expand Up @@ -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<Grant> | 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<Grant> {
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<Grant> {
let active = this.#refreshes.get(machine.id);
if (active) return active;
Expand Down Expand Up @@ -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,
Expand Down
40 changes: 40 additions & 0 deletions apps/server/src/auth/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/auth/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 =>
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 {
Expand Down
47 changes: 47 additions & 0 deletions apps/server/src/auth/session-persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()))!;
Expand Down
Loading
Loading