From 1ccbc3830d62fdd1fe5ddb048803d4d124ba5262 Mon Sep 17 00:00:00 2001 From: FireSquid6 Date: Thu, 13 Aug 2026 13:05:58 -0500 Subject: [PATCH] Keep a relaunched ship's credentials usable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ship provisioned by `fleet launch` was left unusable the second time you launched it: already in the roster, it was skipped for registration and spawned with no credentials at all, so its armory pull was refused. Minting a fresh pair instead is worse — a ShipConnection captures the bridge token it was built with, so rotating that half leaves the bridge presenting a token the restarted ship no longer accepts, and the ship goes offline rather than merely losing its armory. Provisioning now reuses the stored bridge token, which the bridge keeps in the clear because it has to present it, and rotates only the ship token, of which it keeps a hash. The live connection stays valid and the restarted ship gets a working pair, so the skip is no longer needed. A ship's agent token survives provisioning: it is a third credential the bridge re-mints on every connect, and may be in flight in a workspace. This survived until now because nothing ever launched twice. The test that covers it inits the second manager before provisioning, the way a real relaunch does, so the token it asserts against is the one a live connection would be holding. Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/src/launch-command.ts | 42 ++++++---- apps/cli/tests/launch-command.test.ts | 78 ++++++++++++++++++- .../src/content/docs/guides/authentication.md | 21 +++-- .../content/docs/reference/fleet-config.md | 33 ++++---- .../fleet-bridge/src/auth/auth-database.ts | 8 ++ .../fleet-bridge/src/auth/auth-service.ts | 29 +++++-- .../tests/auth-enforcement.test.ts | 15 +++- .../fleet-bridge/tests/auth-service.test.ts | 39 +++++++++- .../fleet-bridge/tests/mutual-auth.test.ts | 72 +++++++++++++++-- .../fleet-bridge/tests/ship-tokens.test.ts | 12 +-- 10 files changed, 290 insertions(+), 59 deletions(-) diff --git a/apps/cli/src/launch-command.ts b/apps/cli/src/launch-command.ts index 4afbe9a..bef996a 100644 --- a/apps/cli/src/launch-command.ts +++ b/apps/cli/src/launch-command.ts @@ -17,6 +17,32 @@ export interface RosterEntry { url: string; } +export interface ShipCredentialPair { + shipToken: string; + bridgeToken: string; +} + +export interface ShipCredentialIssuer { + provisionShipCredentials(shipName: string): ShipCredentialPair; +} + +export function provisionCredentials( + ships: NormalizedShip[], + auth?: ShipCredentialIssuer, +): Map { + const credentials = new Map(); + for (const ship of ships) { + const configured = + ship.shipToken && ship.bridgeToken + ? { shipToken: ship.shipToken, bridgeToken: ship.bridgeToken } + : undefined; + const pair = + configured ?? (ship.source === "local" ? auth?.provisionShipCredentials(ship.name) : undefined); + if (pair) credentials.set(ship.key, pair); + } + return credentials; +} + export type ShipRegistration = { ship: NormalizedShip; url: string } & ( | { skip: null } | { skip: "on-bridge" } @@ -83,19 +109,7 @@ async function runLaunch(configPath: string): Promise { } const shipBridgeUrl = launchedBridgeUrl && isHttpUrl(launchedBridgeUrl) ? launchedBridgeUrl : undefined; - const registrations = planRegistrations(config.ships, manager?.listShips() ?? []); - - const credentials = new Map(); - for (const { ship, skip } of registrations) { - const configured = - ship.shipToken && ship.bridgeToken - ? { shipToken: ship.shipToken, bridgeToken: ship.bridgeToken } - : undefined; - const pair = - configured ?? - (skip === null && ship.source === "local" ? auth?.createShipCredentials(ship.name) : undefined); - if (pair) credentials.set(ship.key, pair); - } + const credentials = provisionCredentials(config.ships, auth); for (const ship of config.ships) { if (ship.source !== "local") continue; @@ -115,7 +129,7 @@ async function runLaunch(configPath: string): Promise { console.log(`no bridge configured; not registering ship "${ship.key}" (${shipUrl(ship)})`); } } else { - for (const entry of registrations) { + for (const entry of planRegistrations(config.ships, manager.listShips())) { const { ship, url } = entry; switch (entry.skip) { case "duplicate-config-entry": diff --git a/apps/cli/tests/launch-command.test.ts b/apps/cli/tests/launch-command.test.ts index 31561e9..f9dcd42 100644 --- a/apps/cli/tests/launch-command.test.ts +++ b/apps/cli/tests/launch-command.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { planRegistrations, shipUrl, type RosterEntry } from "../src/launch-command"; +import { + planRegistrations, + provisionCredentials, + shipUrl, + type RosterEntry, + type ShipCredentialIssuer, +} from "../src/launch-command"; import type { NormalizedShip } from "../src/launch-config"; const local = (key: string, port: number, name = key): NormalizedShip => ({ @@ -104,3 +110,73 @@ describe("planRegistrations", () => { expect(planRegistrations([], [on("ship-a", "http://localhost:4700")])).toEqual([]); }); }); + +function issuerReusingBridgeTokens(): ShipCredentialIssuer & { asked: string[] } { + const bridgeTokens = new Map(); + const asked: string[] = []; + let minted = 0; + return { + asked, + provisionShipCredentials(shipName: string) { + asked.push(shipName); + let bridgeToken = bridgeTokens.get(shipName); + if (bridgeToken === undefined) { + bridgeToken = `bridge-${shipName}`; + bridgeTokens.set(shipName, bridgeToken); + } + minted += 1; + return { shipToken: `ship-${shipName}-${minted}`, bridgeToken }; + }, + }; +} + +const withTokens = (ship: NormalizedShip, shipToken: string, bridgeToken: string): NormalizedShip => ({ + ...ship, + shipToken, + bridgeToken, +}); + +describe("provisionCredentials", () => { + test("provisions a pair for every local ship, keyed by config key and asked for by name", () => { + const auth = issuerReusingBridgeTokens(); + const credentials = provisionCredentials([local("a", 4700, "ship-a"), local("b", 4701, "ship-b")], auth); + + expect(auth.asked).toEqual(["ship-a", "ship-b"]); + expect([...credentials.keys()]).toEqual(["a", "b"]); + expect(credentials.get("a")).toEqual({ shipToken: "ship-ship-a-1", bridgeToken: "bridge-ship-a" }); + }); + + test("a second launch provisions again rather than skipping a ship the bridge already holds", () => { + const auth = issuerReusingBridgeTokens(); + const ships = [local("a", 4700, "ship-a")]; + + const first = provisionCredentials(ships, auth); + const second = provisionCredentials(ships, auth); + + expect(auth.asked).toEqual(["ship-a", "ship-a"]); + expect(second.get("a")?.bridgeToken).toBe(first.get("a")?.bridgeToken); + expect(second.get("a")?.shipToken).not.toBe(first.get("a")?.shipToken); + }); + + test("a configured pair is used verbatim and never asks the bridge for one", () => { + const auth = issuerReusingBridgeTokens(); + const ships = [ + withTokens(local("a", 4700, "ship-a"), "pinned-ship", "pinned-bridge"), + withTokens(remote("b", "http://host-b:4700"), "remote-ship", "remote-bridge"), + ]; + + const credentials = provisionCredentials(ships, auth); + + expect(auth.asked).toEqual([]); + expect(credentials.get("a")).toEqual({ shipToken: "pinned-ship", bridgeToken: "pinned-bridge" }); + expect(credentials.get("b")).toEqual({ shipToken: "remote-ship", bridgeToken: "remote-bridge" }); + }); + + test("a remote ship with no configured pair gets none, and neither does anything without a bridge", () => { + const auth = issuerReusingBridgeTokens(); + + expect(provisionCredentials([remote("b", "http://host-b:4700")], auth).size).toBe(0); + expect(auth.asked).toEqual([]); + expect(provisionCredentials([local("a", 4700, "ship-a"), remote("b", "http://host-b:4700")]).size).toBe(0); + }); +}); diff --git a/apps/docs/src/content/docs/guides/authentication.md b/apps/docs/src/content/docs/guides/authentication.md index 8bb895d..010bbde 100644 --- a/apps/docs/src/content/docs/guides/authentication.md +++ b/apps/docs/src/content/docs/guides/authentication.md @@ -249,9 +249,17 @@ A referenced variable that is unset or empty fails the launch — better than qu registering a ship with no credentials at all. See the [fleet-config reference](/reference/fleet-config/) for the exact rules. -Ships with `source: local` that set neither key keep the behaviour they have always had: -`fleet launch` mints a fresh pair for each one, hands it to the ship it spawns, and -registers it with the bridge. Set both keys on a local ship to pin the pair instead. +Ships with `source: local` that set neither key are provisioned by the launch itself: +`fleet launch` settles a pair for each one, hands it to the ship it spawns, and registers +it with the bridge. Set both keys on a local ship to pin the pair instead. + +Provisioning is not a blind mint. On the first launch both halves are generated. On every +launch after that the bridge **reuses the `bridgeToken` it already stored** for that ship +and rotates only the `shipToken`. It has to: the bridge presents the `bridgeToken` on +every call it makes to the ship, and a connection it has already opened holds the value it +was built with — rotating it would leave the bridge presenting a token the relaunched ship +no longer accepts. The `shipToken` is safe to rotate because only its hash is stored and +the ship is being handed the new one as it starts. ### Starting the remote ship @@ -287,9 +295,10 @@ affected — workspaces on the ship keep working, which is exactly what makes it miss. :::note -A `source: local` ship under `fleet launch` needs neither variable. The launch generates -the pair, hands it to the ship it spawns in-process, and registers it with the bridge in -one step. These two variables are for ships you start yourself. +A `source: local` ship under `fleet launch` needs neither variable. The launch settles the +pair, hands it to the ship it spawns in-process, and registers it with the bridge in one +step — on a relaunch too, where it reuses the stored `bridgeToken` and rotates the +`shipToken`. These two variables are for ships you start yourself. ::: ## `--insecure-no-auth` diff --git a/apps/docs/src/content/docs/reference/fleet-config.md b/apps/docs/src/content/docs/reference/fleet-config.md index d977d32..aca84ea 100644 --- a/apps/docs/src/content/docs/reference/fleet-config.md +++ b/apps/docs/src/content/docs/reference/fleet-config.md @@ -182,8 +182,8 @@ The ship is spawned inside the `fleet launch` process. | `fleetDirectory` | string (non-empty) | no | `./fleet/` | Directory holding this ship's workspaces (`//`). Resolved to an absolute path. | | `port` | integer | no | `4700` | Port this ship listens on. | | `name` | fleet identifier | no | the map key | Human-facing name of this ship. | -| `shipToken` | string (non-empty) | no | freshly minted | The token this ship presents to the bridge. See [ship credentials](#ship-credentials). | -| `bridgeToken` | string (non-empty) | no | freshly minted | The token the bridge presents to this ship. | +| `shipToken` | string (non-empty) | no | provisioned by the launch | The token this ship presents to the bridge. See [ship credentials](#ship-credentials). | +| `bridgeToken` | string (non-empty) | no | provisioned by the launch | The token the bridge presents to this ship. | Because `port` defaults to `4700` for every local ship, two or more local ships must each set a distinct `port`. @@ -216,12 +216,14 @@ fleet launch: ship "gpu-box" sets shipToken but not bridgeToken; a ship is regis What each token does, and which end holds it, is covered in [authentication](/guides/authentication/). What matters here is the default: -- A `source: local` ship with **neither** key set gets a freshly minted pair — - `fleet launch` generates it, hands it to the ship it spawns, and registers the - ship with it. That is the usual case. A pair is only minted for a ship the - launch is about to register: one the bridge's roster already holds keeps the - credentials that roster entry was registered with, and is spawned without a - pair of its own. +- A `source: local` ship with **neither** key set is provisioned by the launch — + `fleet launch` settles a pair, hands it to the ship it spawns, and registers the + ship with it. That is the usual case. On the first launch both halves are + generated; on later launches the bridge reuses the `bridgeToken` it stored for + that ship and rotates only the `shipToken`, so a ship the roster already holds + comes back with credentials the bridge can still use. See + [authentication](/guides/authentication/) for why the `bridgeToken` cannot be + rotated out from under a running bridge. - A `source: local` ship with **both** keys set uses those instead of minting. - A `source: remote` ship with **neither** key set is registered with no credentials, and the bridge talks to it unauthenticated. @@ -299,15 +301,14 @@ duplicate-port check, then the gui/bridge check. 1. Loads and normalizes the config, resolving every `${VAR}` in a ship's tokens. 2. If `bridge` is present, starts the bridge — creating the first admin unless `insecureNoAuth` is set — and keeps its manager. -3. Plans the registrations against the bridge's roster: a ship the bridge already - holds is skipped, and so is a config entry whose URL an earlier entry claimed. -4. Settles each ship's credentials in map order: the configured pair if both keys - are set, otherwise a freshly minted pair for a `source: local` ship the bridge - is about to register, and none for a `source: remote` ship or one step 3 - skipped. -5. Starts every `source: local` ship — pinned to the launched bridge's +3. Settles each ship's credentials in map order: the configured pair if both keys + are set, otherwise a pair provisioned from the bridge for a `source: local` + ship, and none for a `source: remote` ship. +4. Starts every `source: local` ship — pinned to the launched bridge's `publicUrl`, and handed its pair. -6. Registers the ships step 3 planned, at `http://localhost:` (local) or +5. Plans the registrations against the bridge's roster: a ship the bridge already + holds is skipped, and so is a config entry whose URL an earlier entry claimed. +6. Registers the ships step 5 planned, at `http://localhost:` (local) or their `url` (remote), printing `registered ship "" () with the bridge` for each. 7. If `gui` is present, serves the GUI against `gui.bridgeUrl` or the local diff --git a/packages/fleet-bridge/src/auth/auth-database.ts b/packages/fleet-bridge/src/auth/auth-database.ts index 20289ea..6ea21df 100644 --- a/packages/fleet-bridge/src/auth/auth-database.ts +++ b/packages/fleet-bridge/src/auth/auth-database.ts @@ -143,6 +143,9 @@ function prepareStatements(db: Database) { bridge_token = excluded.bridge_token, agent_token_hash = excluded.agent_token_hash`, ), + updateShipTokens: db.query( + "UPDATE ship_credentials SET ship_token_hash = ?, bridge_token = ? WHERE ship_name = ?", + ), findShipCredentials: db.query("SELECT * FROM ship_credentials WHERE ship_name = ?"), findShipByShipTokenHash: db.query( "SELECT * FROM ship_credentials WHERE ship_token_hash = ?", @@ -291,6 +294,11 @@ export class AuthDatabase { ); } + /** Leaves `agent_token_hash` and `created_at` as they are. */ + updateShipTokens(shipName: string, tokens: { shipTokenHash: string; bridgeToken: string }): void { + this.s.updateShipTokens.run(tokens.shipTokenHash, tokens.bridgeToken, shipName); + } + findShipCredentials(shipName: string): ShipCredentialsRow | undefined { return parseRow(ShipCredentialsRowSchema, this.s.findShipCredentials.get(shipName)); } diff --git a/packages/fleet-bridge/src/auth/auth-service.ts b/packages/fleet-bridge/src/auth/auth-service.ts index 0de016b..febe0ad 100644 --- a/packages/fleet-bridge/src/auth/auth-service.ts +++ b/packages/fleet-bridge/src/auth/auth-service.ts @@ -257,11 +257,30 @@ export class AuthService { return entry.expiresAt <= this.now() ? null : entry.principal; } - createShipCredentials(shipName: string): { shipToken: string; bridgeToken: string } { - const shipToken = generateToken(); - const bridgeToken = generateToken(); - this.setShipCredentials(shipName, { shipToken, bridgeToken }); - return { shipToken, bridgeToken }; + /** + * Reuses any stored `bridgeToken`: a live `ShipConnection` captured that + * value when it was built and cannot be told a new one, so rotating it would + * leave the bridge presenting a token the restarted ship no longer accepts. + */ + provisionShipCredentials(shipName: string): { shipToken: string; bridgeToken: string } { + const name = parseFleetIdentifier(shipName); + return this.db.immediateTransaction(() => { + const existing = this.db.findShipCredentials(name); + const shipToken = generateToken(); + const bridgeToken = existing?.bridge_token ?? generateToken(); + if (existing) { + this.db.updateShipTokens(name, { shipTokenHash: hashToken(shipToken), bridgeToken }); + } else { + this.db.upsertShipCredentials({ + ship_name: name, + ship_token_hash: hashToken(shipToken), + bridge_token: bridgeToken, + agent_token_hash: null, + created_at: this.now(), + }); + } + return { shipToken, bridgeToken }; + }); } setShipCredentials(shipName: string, credentials: { shipToken: string; bridgeToken: string }): void { diff --git a/packages/fleet-bridge/tests/auth-enforcement.test.ts b/packages/fleet-bridge/tests/auth-enforcement.test.ts index 83425a9..3964f19 100644 --- a/packages/fleet-bridge/tests/auth-enforcement.test.ts +++ b/packages/fleet-bridge/tests/auth-enforcement.test.ts @@ -87,7 +87,7 @@ describe("bridge default-deny enforcement", () => { test("the armory routes answer a ship or a user credential and nothing else", async () => { const { app, auth, authorization } = await makeAuthedApp(manager); - const { shipToken } = auth.createShipCredentials("ship-a"); + const { shipToken } = auth.provisionShipCredentials("ship-a"); const asShip = { headers: { authorization: `Bearer ${shipToken}` } }; const asUser = { headers: { authorization } }; @@ -100,6 +100,17 @@ describe("bridge default-deny enforcement", () => { expect(await status(app, "GET", "/armory/file?path=nope", asUser)).toBe(404); }); + test("a relaunched ship reaches the armory with its new token, and its old one is dead", async () => { + const { app, auth } = await makeAuthedApp(manager); + const first = auth.provisionShipCredentials("ship-a"); + const second = auth.provisionShipCredentials("ship-a"); + + const withToken = (token: string) => ({ headers: { authorization: `Bearer ${token}` } }); + + expect(await status(app, "GET", "/armory", withToken(second.shipToken))).toBe(200); + expect(await status(app, "GET", "/armory", withToken(first.shipToken))).toBe(401); + }); + test("insecureNoAuth serves every route without credentials", async () => { const { app } = await makeAuthedApp(manager, { insecureNoAuth: true }); @@ -115,7 +126,7 @@ describe("bridge default-deny enforcement", () => { let app: ReturnType; async function tokenFor(kind: "ship" | "ship-agent"): Promise { - const { shipToken } = auth.createShipCredentials("ship-a"); + const { shipToken } = auth.provisionShipCredentials("ship-a"); return kind === "ship" ? shipToken : auth.mintShipAgentToken("ship-a"); } diff --git a/packages/fleet-bridge/tests/auth-service.test.ts b/packages/fleet-bridge/tests/auth-service.test.ts index 06da2ad..dac880e 100644 --- a/packages/fleet-bridge/tests/auth-service.test.ts +++ b/packages/fleet-bridge/tests/auth-service.test.ts @@ -194,7 +194,7 @@ describe("AuthService", () => { test("session, ship, and ship-agent tokens resolve to distinct principals", async () => { const user = await auth.createUser({ username: "ada", email: "ada@fleet.test", password: PASSWORD, role: "admin" }); const { token } = await auth.login("ada", PASSWORD); - const { shipToken, bridgeToken } = auth.createShipCredentials("ship-a"); + const { shipToken, bridgeToken } = auth.provisionShipCredentials("ship-a"); const agentToken = auth.mintShipAgentToken("ship-a"); expect(auth.authenticate(`Bearer ${token}`)).toEqual({ @@ -214,7 +214,7 @@ describe("AuthService", () => { expect(auth.bridgeTokenFor("ship-a")).toBe("bridge-secret"); expect(auth.authenticate("Bearer ship-secret")).toEqual({ kind: "ship", ship: "ship-a" }); - const rotated = auth.createShipCredentials("ship-a"); + const rotated = auth.provisionShipCredentials("ship-a"); expect(auth.authenticate("Bearer ship-secret")).toBeNull(); expect(auth.authenticate(`Bearer ${rotated.shipToken}`)).toEqual({ kind: "ship", ship: "ship-a" }); expect(db.findShipCredentials("ship-a")?.ship_token_hash).toBe(sha256(rotated.shipToken)); @@ -225,6 +225,41 @@ describe("AuthService", () => { expect(() => auth.mintShipAgentToken("ship-a")).toThrow(); }); + test("provisioning a ship the database has never seen mints both halves", () => { + const a = auth.provisionShipCredentials("ship-a"); + const b = auth.provisionShipCredentials("ship-b"); + + expect(new Set([a.shipToken, a.bridgeToken, b.shipToken, b.bridgeToken]).size).toBe(4); + expect(auth.bridgeTokenFor("ship-a")).toBe(a.bridgeToken); + expect(auth.authenticate(`Bearer ${a.shipToken}`)).toEqual({ kind: "ship", ship: "ship-a" }); + expect(db.findShipCredentials("ship-a")?.agent_token_hash).toBeNull(); + }); + + test("provisioning again reuses the stored bridgeToken and rotates only the shipToken", () => { + const first = auth.provisionShipCredentials("ship-a"); + const createdAt = db.findShipCredentials("ship-a")?.created_at; + + clock += 60_000; + const second = auth.provisionShipCredentials("ship-a"); + + expect(second.bridgeToken).toBe(first.bridgeToken); + expect(second.shipToken).not.toBe(first.shipToken); + expect(auth.bridgeTokenFor("ship-a")).toBe(first.bridgeToken); + expect(auth.authenticate(`Bearer ${first.shipToken}`)).toBeNull(); + expect(auth.authenticate(`Bearer ${second.shipToken}`)).toEqual({ kind: "ship", ship: "ship-a" }); + expect(db.findShipCredentials("ship-a")?.ship_token_hash).toBe(sha256(second.shipToken)); + expect(db.findShipCredentials("ship-a")?.created_at).toBe(createdAt); + }); + + test("provisioning leaves an agent token the bridge already pushed alone", () => { + auth.provisionShipCredentials("ship-a"); + const agentToken = auth.mintShipAgentToken("ship-a"); + + auth.provisionShipCredentials("ship-a"); + + expect(auth.authenticate(`Bearer ${agentToken}`)).toEqual({ kind: "ship-agent", ship: "ship-a" }); + }); + test("authenticate ignores malformed authorization headers", async () => { await auth.createUser({ username: "ada", email: "ada@fleet.test", password: PASSWORD }); const { token } = await auth.login("ada", PASSWORD); diff --git a/packages/fleet-bridge/tests/mutual-auth.test.ts b/packages/fleet-bridge/tests/mutual-auth.test.ts index 95e18ff..0fc350b 100644 --- a/packages/fleet-bridge/tests/mutual-auth.test.ts +++ b/packages/fleet-bridge/tests/mutual-auth.test.ts @@ -29,6 +29,17 @@ const noArmory = { }, } as unknown as ArmoryService; +async function waitForStatus(manager: FleetManager, name: string, timeoutMs = 20_000): Promise { + const deadline = Date.now() + timeoutMs; + let status = "missing"; + while (Date.now() < deadline) { + status = manager.listShips().find((each) => each.name === name)?.status ?? "missing"; + if (status === "online") break; + await Bun.sleep(50); + } + return status; +} + describe("bridge and ship authenticate each other", () => { let dir: string; let store: Store; @@ -51,16 +62,16 @@ describe("bridge and ship authenticate each other", () => { await rm(dir, { recursive: true, force: true }); }); - function startShip(bridgeToken?: string, shipToken?: string, agentToken?: string): string { + function startShip(bridgeToken?: string, shipToken?: string, agentToken?: string, port = 0): string { ship = createShipApp( shipManager(), - { fleetDirectory: dir, port: 0, name: "ship-a", bridgeToken, shipToken, agentToken }, + { fleetDirectory: dir, port, name: "ship-a", bridgeToken, shipToken, agentToken }, undefined, undefined, undefined, {}, ); - ship.listen(0); + ship.listen(port); return `http://localhost:${ship.server?.port}`; } @@ -75,7 +86,7 @@ describe("bridge and ship authenticate each other", () => { } test("a launched ship's minted pair gets the bridge in, over both the socket and HTTP", async () => { - const credentials = auth.createShipCredentials("ship-a"); + const credentials = auth.provisionShipCredentials("ship-a"); const url = startShip(credentials.bridgeToken, credentials.shipToken); const mgr = buildManager(); await mgr.init(); @@ -87,7 +98,7 @@ describe("bridge and ship authenticate each other", () => { }); test("the bridge cannot reach a token-holding ship without that ship's bridgeToken", async () => { - const credentials = auth.createShipCredentials("ship-a"); + const credentials = auth.provisionShipCredentials("ship-a"); const url = startShip(credentials.bridgeToken, credentials.shipToken); const mgr = buildManager(); await mgr.init(); @@ -100,7 +111,7 @@ describe("bridge and ship authenticate each other", () => { }); test("an agent asks its ship for a bridge credential the bridge honours", async () => { - const credentials = auth.createShipCredentials("ship-a"); + const credentials = auth.provisionShipCredentials("ship-a"); const url = startShip(credentials.bridgeToken, credentials.shipToken, "agent-secret"); const mgr = buildManager(); await mgr.init(); @@ -118,7 +129,7 @@ describe("bridge and ship authenticate each other", () => { }); test("a ship keeps its bridge credential out of reach of the bridge's own token holders", async () => { - const credentials = auth.createShipCredentials("ship-a"); + const credentials = auth.provisionShipCredentials("ship-a"); const url = startShip(credentials.bridgeToken, credentials.shipToken, "agent-secret"); const mgr = buildManager(); await mgr.init(); @@ -131,6 +142,53 @@ describe("bridge and ship authenticate each other", () => { expect(wrong.status).toBe(401); }); + test("a second launch reaches the ship it relaunched with a rotated shipToken", async () => { + const first = auth.provisionShipCredentials("ship-a"); + const url = startShip(first.bridgeToken, first.shipToken); + const port = Number(new URL(url).port); + + const firstLaunch = buildManager(); + await firstLaunch.init(); + await firstLaunch.addShip(url, first); + expect(firstLaunch.listShips()).toMatchObject([{ name: "ship-a", status: "online" }]); + firstLaunch.shutdown(); + ship?.server?.stop(true); + + const secondLaunch = buildManager(); + await secondLaunch.init(); + const captured = auth.bridgeTokenFor("ship-a"); + + const second = auth.provisionShipCredentials("ship-a"); + expect(second.bridgeToken).toBe(first.bridgeToken); + expect(second.shipToken).not.toBe(first.shipToken); + expect(startShip(second.bridgeToken, second.shipToken, undefined, port)).toBe(url); + + const asBridge = await fetch(`${url}/system-resources`, { + headers: { authorization: `Bearer ${captured}` }, + }); + expect(asBridge.status).toBe(200); + + expect(await waitForStatus(secondLaunch, "ship-a")).toBe("online"); + expect(await secondLaunch.getShipSystemResources("ship-a")).toMatchObject({ + os: { platform: expect.any(String) }, + }); + }, 30_000); + + test("a relaunched ship refuses a bridgeToken that was rotated out from under it", async () => { + const first = auth.provisionShipCredentials("ship-a"); + const url = startShip(first.bridgeToken, first.shipToken); + + const stale = await fetch(`${url}/system-resources`, { + headers: { authorization: "Bearer a-bridge-token-this-ship-was-never-given" }, + }); + expect(stale.status).toBe(401); + + const held = await fetch(`${url}/system-resources`, { + headers: { authorization: `Bearer ${auth.bridgeTokenFor("ship-a")}` }, + }); + expect(held.status).toBe(200); + }); + test("a ship with no bridgeToken still admits a bridge that has none", async () => { const url = startShip(); const mgr = buildManager(); diff --git a/packages/fleet-bridge/tests/ship-tokens.test.ts b/packages/fleet-bridge/tests/ship-tokens.test.ts index 652cc69..1c36e79 100644 --- a/packages/fleet-bridge/tests/ship-tokens.test.ts +++ b/packages/fleet-bridge/tests/ship-tokens.test.ts @@ -42,7 +42,7 @@ describe("ship credentials", () => { ["http://ship-a", { name: "ship-a", workspaces: [ws("repo1", "one")] }], ]); await store.createShip({ name: "ship-a", url: "http://ship-a" }); - const { bridgeToken } = auth.createShipCredentials("ship-a"); + const { bridgeToken } = auth.provisionShipCredentials("ship-a"); const mgr = build(ships); await mgr.init(); @@ -104,7 +104,7 @@ describe("ship credentials", () => { test("pushes a freshly minted ship-agent credential to a credentialed ship on connect", async () => { const ships = new Map([["http://ship-a", { name: "ship-a", workspaces: [] }]]); await store.createShip({ name: "ship-a", url: "http://ship-a" }); - auth.createShipCredentials("ship-a"); + auth.provisionShipCredentials("ship-a"); const mgr = build(ships); await mgr.init(); @@ -119,7 +119,7 @@ describe("ship credentials", () => { test("re-mints on every reconnect, invalidating the token it pushed before", async () => { const ships = new Map([["http://ship-a", { name: "ship-a", workspaces: [] }]]); await store.createShip({ name: "ship-a", url: "http://ship-a" }); - auth.createShipCredentials("ship-a"); + auth.provisionShipCredentials("ship-a"); const mgr = build(ships); await mgr.init(); @@ -164,7 +164,7 @@ describe("ship credentials", () => { test("removeShip deletes the ship's credentials", async () => { const ships = new Map([["http://ship-a", { name: "ship-a", workspaces: [] }]]); await store.createShip({ name: "ship-a", url: "http://ship-a" }); - const { shipToken } = auth.createShipCredentials("ship-a"); + const { shipToken } = auth.provisionShipCredentials("ship-a"); const mgr = build(ships); await mgr.init(); @@ -179,7 +179,7 @@ describe("ship credentials", () => { ["http://ship-a:3001", { name: "ship-a", workspaces: [ws("repo1", "one")] }], ]); await store.createShip({ name: "ship-a", url: "http://ship-a:3001" }); - const { bridgeToken } = auth.createShipCredentials("ship-a"); + const { bridgeToken } = auth.provisionShipCredentials("ship-a"); const mgr = build(ships); await mgr.init(); @@ -215,7 +215,7 @@ describe("ship credentials", () => { const url = `http://localhost:${upstream.port}`; const ships = new Map([[url, { name: "ship-a", workspaces: [ws("repo1", "one")] }]]); await store.createShip({ name: "ship-a", url }); - const { bridgeToken } = auth.createShipCredentials("ship-a"); + const { bridgeToken } = auth.provisionShipCredentials("ship-a"); const mgr = build(ships); await mgr.init();