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();