diff --git a/.gitignore b/.gitignore index 6b48806e..86a1ce1c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,9 @@ V1_PROGRESS.md # Runtime E2E artifacts and python cache artifacts/ __pycache__/ + +# v3 collab secrets (abg auth login) — never commit raw PSK tokens / identity DB +collab.db +collab.db-wal +collab.db-shm +auth-token diff --git a/plugins/agentbridge/server/bridge-server.js b/plugins/agentbridge/server/bridge-server.js index 248d0bcc..0f7334d7 100755 --- a/plugins/agentbridge/server/bridge-server.js +++ b/plugins/agentbridge/server/bridge-server.js @@ -14707,10 +14707,10 @@ function defineNumber(value, fallback) { } var BUILD_INFO = Object.freeze({ version: defineString("0.1.24", "0.0.0-source"), - commit: defineString("c5b4172", "source"), + commit: defineString("dbefc72", "source"), bundle: defineBundle("plugin"), contractVersion: defineNumber(1, CONTRACT_VERSION), - codeHash: defineString("3705b611c554", "source") + codeHash: defineString("fc5f11bd22b6", "source") }); function sameRuntimeContract(a, b) { if (!a || !b) diff --git a/plugins/agentbridge/server/daemon.js b/plugins/agentbridge/server/daemon.js index a066d0c9..1cb62d74 100755 --- a/plugins/agentbridge/server/daemon.js +++ b/plugins/agentbridge/server/daemon.js @@ -30,10 +30,10 @@ function defineNumber(value, fallback) { } var BUILD_INFO = Object.freeze({ version: defineString("0.1.24", "0.0.0-source"), - commit: defineString("c5b4172", "source"), + commit: defineString("dbefc72", "source"), bundle: defineBundle("plugin"), contractVersion: defineNumber(1, CONTRACT_VERSION), - codeHash: defineString("3705b611c554", "source") + codeHash: defineString("fc5f11bd22b6", "source") }); function daemonStatusBuildInfo() { return { ...BUILD_INFO }; diff --git a/src/backbone/identity-service.ts b/src/backbone/identity-service.ts new file mode 100644 index 00000000..f5d115f8 --- /dev/null +++ b/src/backbone/identity-service.ts @@ -0,0 +1,63 @@ +import { randomUUID } from "node:crypto"; +import type { Store } from "./store"; +import type { Identity } from "./identity"; + +/** + * Three-layer identity service (§2.1–2.2) over a Store. + * + * Enforces the id/displayName separation: `id` (an email or GitHub name) is the + * UNIQUE routing key the core uses for signing / routing / membership; the + * displayName is display-only and may collide. Persists person identities, + * logical agents (e.g. "Alice's Claude Code") and sessions, and issues PSK + * tokens bound to an identity (`abg auth login`). + * + * The three layers map to Store rows: identities (person), agents (logical + * agent, FK personId), sessions (ephemeral). Re-registering the same id reuses + * the row (a second device → same identity), never a new one (§2.2). + */ +export class IdentityService { + constructor(private readonly store: Store) {} + + /** Register a person identity (or update its display name). Returns it. */ + async registerIdentity(id: string, displayName: string): Promise { + const trimmed = id.trim(); + if (trimmed === "") throw new Error("identity id must be non-empty (use an email or GitHub name)"); + const rec = await this.store.upsertIdentity(trimmed, displayName); + return { id: rec.id, displayName: rec.displayName }; + } + + async getIdentity(id: string): Promise { + const rec = await this.store.getIdentity(id); + return rec ? { id: rec.id, displayName: rec.displayName } : null; + } + + /** Register a logical agent under a person (§2.1). Re-register updates type. */ + async registerAgent(agentId: string, personId: string, type: string): Promise { + await this.store.upsertAgent(agentId, personId, type); + } + + async recordSession(sessionId: string, agentId: string, startedAt: number): Promise { + await this.store.recordSession(sessionId, agentId, startedAt); + } + + /** Roll a logical agent up to its owning person id (§2.1). Null if unknown. */ + async resolvePerson(agentId: string): Promise { + const agent = await this.store.getAgent(agentId); + return agent ? agent.personId : null; + } + + /** + * Issue a fresh PSK token bound to an existing identity (`abg auth login`). + * The identity MUST already be registered. Returns the opaque token to hand to + * the user; the (token → identity) binding is persisted for the broker to + * verify via {@link Store.resolveToken}. + */ + async issueToken(identityId: string): Promise { + if (!(await this.store.getIdentity(identityId))) { + throw new Error(`unknown identity: ${identityId} (register it first)`); + } + const token = randomUUID(); + await this.store.issueToken(token, identityId); + return token; + } +} diff --git a/src/backbone/identity/store-psk-identity-provider.ts b/src/backbone/identity/store-psk-identity-provider.ts new file mode 100644 index 00000000..38720632 --- /dev/null +++ b/src/backbone/identity/store-psk-identity-provider.ts @@ -0,0 +1,32 @@ +import type { Store } from "../store"; +import type { Identity, IdentityProvider } from "../identity"; + +/** + * Store-backed PSK IdentityProvider — the real broker auth driver (§6.2). + * + * Resolves a presented token via the Store's persisted (token → identity) + * bindings issued by `abg auth login`. Unlike the in-memory PskIdentityProvider + * (seeded at construction), this reads the live Store, so a freshly-issued token + * authenticates without restarting the broker. + * + * Note (security, MVP): tokens are stored raw in the local Store. The collab DB + * file itself is 0644 (bun:sqlite default), so its CONTAINING directory is locked + * to 0700 by the writer (`abg auth login`, src/cli/auth.ts) to block other local + * users — the durable equivalent of control-token.ts's 0600 file. The token is an + * unguessable randomUUID and the link is WireGuard-encrypted over Tailscale (§7). + * Hashing tokens at rest is a §11.3 hardening item. + */ +export class StorePskIdentityProvider implements IdentityProvider { + constructor(private readonly store: Store) {} + + async authenticate(credential: string): Promise { + const identityId = await this.store.resolveToken(credential); + if (!identityId) throw new Error("invalid PSK token"); + const identity = await this.store.getIdentity(identityId); + if (!identity) { + // The token's identity row was deleted out from under it. + throw new Error(`token resolved to unknown identity: ${identityId}`); + } + return { id: identity.id, displayName: identity.displayName }; + } +} diff --git a/src/backbone/store.ts b/src/backbone/store.ts index f845d6f0..ff517657 100644 --- a/src/backbone/store.ts +++ b/src/backbone/store.ts @@ -87,6 +87,14 @@ export interface Store { /** Remove and return the target's pending envelopes (deduped by idempotencyKey). */ drainPending(targetAgentId: string): Promise; + // --- auth tokens (§6.2): `abg auth login` binds a PSK token to an identity --- + /** Persist a token → identity binding. Re-issuing the same token re-points it. */ + issueToken(token: string, identityId: string): Promise; + /** Resolve a presented token to its identity id, or null if unknown. */ + resolveToken(token: string): Promise; + /** All issued (token, identityId) bindings — e.g. to seed an in-memory PSK provider. */ + listTokens(): Promise>; + /** Release resources (close the DB handle). Idempotent. */ close(): Promise; } diff --git a/src/backbone/store/memory-store.ts b/src/backbone/store/memory-store.ts index dcac5c1b..8429ab8b 100644 --- a/src/backbone/store/memory-store.ts +++ b/src/backbone/store/memory-store.ts @@ -23,6 +23,7 @@ export class InMemoryStore implements Store { private whiteboards = new Map(); // targetAgentId → (idempotencyKey → envelope); Map preserves insertion order private pending = new Map>(); + private tokens = new Map(); // token → identityId async upsertIdentity(id: string, displayName: string): Promise { const record = { id, displayName }; @@ -148,6 +149,18 @@ export class InMemoryStore implements Store { return [...byKey.values()]; } + async issueToken(token: string, identityId: string): Promise { + this.tokens.set(token, identityId); // re-issue re-points + } + + async resolveToken(token: string): Promise { + return this.tokens.get(token) ?? null; + } + + async listTokens(): Promise> { + return [...this.tokens.entries()].map(([token, identityId]) => ({ token, identityId })); + } + async close(): Promise { // No handle to release; idempotent no-op. } diff --git a/src/backbone/store/postgres-store.ts b/src/backbone/store/postgres-store.ts index ee89009b..39fe0d1c 100644 --- a/src/backbone/store/postgres-store.ts +++ b/src/backbone/store/postgres-store.ts @@ -108,6 +108,18 @@ export class PostgresStore implements Store { throw new Error(NOT_IMPLEMENTED); } + async issueToken(_token: string, _identityId: string): Promise { + throw new Error(NOT_IMPLEMENTED); + } + + async resolveToken(_token: string): Promise { + throw new Error(NOT_IMPLEMENTED); + } + + async listTokens(): Promise> { + throw new Error(NOT_IMPLEMENTED); + } + async close(): Promise { throw new Error(NOT_IMPLEMENTED); } diff --git a/src/backbone/store/sqlite-store.ts b/src/backbone/store/sqlite-store.ts index 68b000d4..5ea15ba1 100644 --- a/src/backbone/store/sqlite-store.ts +++ b/src/backbone/store/sqlite-store.ts @@ -70,6 +70,10 @@ export class SqliteStore implements Store { envelope TEXT NOT NULL, UNIQUE (target_agent_id, idempotency_key) ); + CREATE TABLE IF NOT EXISTS auth_tokens ( + token TEXT PRIMARY KEY, + identity_id TEXT NOT NULL + ); `); } @@ -250,6 +254,29 @@ export class SqliteStore implements Store { return rows.map((r) => JSON.parse(r.envelope) as Envelope); } + // --- auth tokens --- + async issueToken(token: string, identityId: string): Promise { + this.db + .query( + "INSERT INTO auth_tokens(token, identity_id) VALUES(?, ?) ON CONFLICT(token) DO UPDATE SET identity_id=excluded.identity_id", + ) + .run(token, identityId); + } + + async resolveToken(token: string): Promise { + const row = this.db + .query("SELECT identity_id FROM auth_tokens WHERE token=?") + .get(token) as { identity_id: string } | null; + return row ? row.identity_id : null; + } + + async listTokens(): Promise> { + const rows = this.db + .query("SELECT token, identity_id FROM auth_tokens") + .all() as { token: string; identity_id: string }[]; + return rows.map((r) => ({ token: r.token, identityId: r.identity_id })); + } + async close(): Promise { if (this.closed) return; this.closed = true; diff --git a/src/cli.ts b/src/cli.ts index 085fdcdd..fe699d9e 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -119,6 +119,10 @@ async function main(command: string | undefined, restArgs: string[]) { const { runLogs } = await import("./cli/logs"); await runLogs(restArgs); break; + case "auth": + const { runAuth } = await import("./cli/auth"); + await runAuth(restArgs); + break; case "--help": case "-h": case undefined: @@ -163,6 +167,8 @@ Commands: doctor [--json] Diagnose env, daemon, build drift, logs, and current thread doctor resume-pollution [--apply] Find/fix old AgentBridge kickoff metadata budget [--json] Show both agents' subscription quota snapshot (5h/weekly, drift, pause state) + auth login --id --name + Issue a collaboration PSK token and write it to /auth-token (0600) logs [--codex] [-f] [-n N] Tail this pair's daemon log (or the codex wrapper log with --codex). -n N: last N lines (default 100). -f: follow/stream. diff --git a/src/cli/auth.ts b/src/cli/auth.ts new file mode 100644 index 00000000..f37c4d20 --- /dev/null +++ b/src/cli/auth.ts @@ -0,0 +1,112 @@ +/** + * `abg auth login` — issue a PSK token bound to a collaboration identity (§2.2, §6). + * + * Registers (or refreshes) a person identity in the local collab Store, issues a + * fresh PSK token, and writes it to `/auth-token` (0600) for the broker to + * verify via StorePskIdentityProvider. The Store double as the (token → identity) + * binding source, so a freshly-issued token authenticates without a broker restart. + */ + +import { chmodSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { atomicWriteText } from "../atomic-json"; +import { IdentityService } from "../backbone/identity-service"; +import { SqliteStore } from "../backbone/store/sqlite-store"; +import { StateDirResolver } from "../state-dir"; + +export interface AuthLoginOptions { + id: string; + name: string; + dbPath?: string; +} + +export interface AuthLoginResult { + token: string; + identity: { id: string; displayName: string }; + tokenFile: string; +} + +/** Resolve the collab DB path: explicit > env override > `/collab.db`. */ +function resolveDbPath(dbPath?: string): string { + if (dbPath) return dbPath; + const env = process.env.AGENTBRIDGE_COLLAB_DB; + if (env && env.length > 0) return env; + return join(new StateDirResolver().dir, "collab.db"); +} + +/** + * Register the identity, issue a token, and persist it next to the collab DB. + * Directly unit-testable: pass an explicit `dbPath` to a temp dir. + */ +export async function authLogin(opts: AuthLoginOptions): Promise { + const dbPath = resolveDbPath(opts.dbPath); + const dir = dirname(dbPath); + // The collab DB holds RAW PSK tokens (auth_tokens) + identity emails/PII + // (identities). bun:sqlite creates the DB file 0644, and its WAL/SHM sidecars + // are recreated 0644 on every reopen, so file-level chmod is not durable — + // lock the CONTAINING directory to 0700 instead (matches codex-transport.ts), + // blocking any other local user from traversing in to read the secrets + // (CWE-732). chmodSync covers the case where the dir already existed looser. + mkdirSync(dir, { recursive: true, mode: 0o700 }); + chmodSync(dir, 0o700); + + const store = new SqliteStore(dbPath); + try { + const svc = new IdentityService(store); + const identity = await svc.registerIdentity(opts.id, opts.name); + const token = await svc.issueToken(identity.id); + const tokenFile = join(dir, "auth-token"); + // 0600 from creation (CWE-732): the token is a local secret. + atomicWriteText(tokenFile, token, { mode: 0o600 }); + return { token, identity, tokenFile }; + } finally { + await store.close(); + } +} + +const LOGIN_USAGE = "用法:abg auth login --id --name "; + +/** Parse `--id`/`--name` (space- or `=`-separated) and run the login. */ +export async function runAuthLoginCli(argv: string[]): Promise { + let id: string | undefined; + let name: string | undefined; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]!; + if (a === "--id") { + id = argv[++i]; + } else if (a.startsWith("--id=")) { + id = a.slice("--id=".length); + } else if (a === "--name") { + name = argv[++i]; + } else if (a.startsWith("--name=")) { + name = a.slice("--name=".length); + } + } + + if (!id || !name) { + console.error("缺少必填参数 --id 或 --name。"); + console.error(LOGIN_USAGE); + process.exit(1); + return; + } + + const result = await authLogin({ id, name }); + console.log( + `已为 ${result.identity.id}(${result.identity.displayName})签发令牌:${result.token}`, + ); + console.log(`令牌文件:${result.tokenFile}`); +} + +/** Dispatch `abg auth `. Only `login` is supported today. */ +export async function runAuth(args: string[]): Promise { + const sub = args[0]; + switch (sub) { + case "login": + await runAuthLoginCli(args.slice(1)); + break; + default: + console.error(`未知的 auth 子命令:${sub ?? "(空)"}`); + console.error(LOGIN_USAGE); + process.exit(1); + } +} diff --git a/src/unit-test/cli-auth.test.ts b/src/unit-test/cli-auth.test.ts new file mode 100644 index 00000000..2794b16c --- /dev/null +++ b/src/unit-test/cli-auth.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { authLogin } from "../cli/auth"; +import { StorePskIdentityProvider } from "../backbone/identity/store-psk-identity-provider"; +import { SqliteStore } from "../backbone/store/sqlite-store"; + +describe("authLogin", () => { + let dir: string | undefined; + + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = undefined; + }); + + it("issues a persisted 0600 token that round-trips via StorePskIdentityProvider", async () => { + dir = mkdtempSync(join(tmpdir(), "agentbridge-auth-")); + const dbPath = join(dir, "collab.db"); + + const result = await authLogin({ id: "alice@x.com", name: "Alice", dbPath }); + + // token + identity shape + expect(result.token).toBeTruthy(); + expect(result.identity).toEqual({ id: "alice@x.com", displayName: "Alice" }); + + // token file exists, content matches, and is 0600 + expect(existsSync(result.tokenFile)).toBe(true); + expect(readFileSync(result.tokenFile, "utf-8")).toBe(result.token); + expect(statSync(result.tokenFile).mode & 0o777).toBe(0o600); + + // the issued token authenticates back to the same identity + const store = new SqliteStore(dbPath); + try { + const provider = new StorePskIdentityProvider(store); + const identity = await provider.authenticate(result.token); + expect(identity).toEqual({ id: "alice@x.com", displayName: "Alice" }); + } finally { + await store.close(); + } + }); + + it("locks a freshly-created collab DB directory to 0700 (raw tokens + PII at rest)", async () => { + dir = mkdtempSync(join(tmpdir(), "agentbridge-auth-")); + const collabDir = join(dir, "nested", "collab"); // does not exist yet → mkdirSync creates it + await authLogin({ id: "bob@x.com", name: "Bob", dbPath: join(collabDir, "collab.db") }); + // collab.db is 0644 (bun:sqlite default), so the directory must block traversal. + expect(statSync(collabDir).mode & 0o777).toBe(0o700); + }); + + it("tightens a pre-existing loose collab dir to 0700", async () => { + dir = mkdtempSync(join(tmpdir(), "agentbridge-auth-")); + const collabDir = join(dir, "loose"); + mkdirSync(collabDir); + chmodSync(collabDir, 0o755); // simulate a world-traversable dir + await authLogin({ id: "c@x.com", name: "C", dbPath: join(collabDir, "collab.db") }); + expect(statSync(collabDir).mode & 0o777).toBe(0o700); + }); +}); diff --git a/src/unit-test/identity-service.test.ts b/src/unit-test/identity-service.test.ts new file mode 100644 index 00000000..10c318be --- /dev/null +++ b/src/unit-test/identity-service.test.ts @@ -0,0 +1,58 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import { IdentityService } from "../backbone/identity-service"; +import { InMemoryStore } from "../backbone/store/memory-store"; + +describe("IdentityService — three-layer identity + id/name separation", () => { + let store: InMemoryStore; + let svc: IdentityService; + beforeEach(() => { + store = new InMemoryStore(); + svc = new IdentityService(store); + }); + + test("registerIdentity separates id/displayName, trims id, reuses on same id", async () => { + const a = await svc.registerIdentity(" alice@x.com ", "Alice"); + expect(a).toEqual({ id: "alice@x.com", displayName: "Alice" }); + // two Bobs: same display name allowed, distinct ids + await svc.registerIdentity("bob1@x.com", "Bob"); + await svc.registerIdentity("bob2@x.com", "Bob"); + expect((await svc.getIdentity("bob1@x.com"))!.id).not.toBe( + (await svc.getIdentity("bob2@x.com"))!.id, + ); + // same id (second device) reuses the row and updates the display name + await svc.registerIdentity("alice@x.com", "Alice (laptop)"); + expect(await svc.getIdentity("alice@x.com")).toEqual({ + id: "alice@x.com", + displayName: "Alice (laptop)", + }); + }); + + test("empty id is rejected", async () => { + await expect(svc.registerIdentity(" ", "X")).rejects.toThrow(); + }); + + test("registerAgent + resolvePerson rolls a logical agent up to its person", async () => { + await svc.registerIdentity("alice@x.com", "Alice"); + await svc.registerAgent("ag-claude", "alice@x.com", "claude"); + await svc.registerAgent("ag-cursor", "alice@x.com", "cursor"); + expect(await svc.resolvePerson("ag-claude")).toBe("alice@x.com"); + expect(await svc.resolvePerson("ag-cursor")).toBe("alice@x.com"); + expect(await svc.resolvePerson("ag-unknown")).toBeNull(); + }); + + test("issueToken binds a fresh token to an existing identity; unknown identity rejected", async () => { + await svc.registerIdentity("alice@x.com", "Alice"); + const token = await svc.issueToken("alice@x.com"); + expect(typeof token).toBe("string"); + expect(token.length).toBeGreaterThan(0); + expect(await store.resolveToken(token)).toBe("alice@x.com"); + await expect(svc.issueToken("nobody@x.com")).rejects.toThrow(); + }); + + test("two issued tokens for the same identity are distinct", async () => { + await svc.registerIdentity("alice@x.com", "Alice"); + const t1 = await svc.issueToken("alice@x.com"); + const t2 = await svc.issueToken("alice@x.com"); + expect(t1).not.toBe(t2); + }); +}); diff --git a/src/unit-test/store-contract.ts b/src/unit-test/store-contract.ts index 3a91d041..993bf6be 100644 --- a/src/unit-test/store-contract.ts +++ b/src/unit-test/store-contract.ts @@ -123,5 +123,18 @@ export function runStoreContract(label: string, makeStore: () => Store) { expect(await store.drainPending("ag-2")).toEqual([]); // cleared expect((await store.drainPending("ag-3")).length).toBe(1); // other target intact }); + + test("auth tokens issue / resolve / list, re-issue re-points", async () => { + expect(await store.resolveToken("tok-1")).toBeNull(); + await store.issueToken("tok-1", "alice@x.com"); + await store.issueToken("tok-2", "bob@x.com"); + expect(await store.resolveToken("tok-1")).toBe("alice@x.com"); + expect(await store.resolveToken("tok-2")).toBe("bob@x.com"); + // re-issuing the same token re-points it to a new identity + await store.issueToken("tok-1", "carol@x.com"); + expect(await store.resolveToken("tok-1")).toBe("carol@x.com"); + const all = (await store.listTokens()).map((t) => `${t.token}:${t.identityId}`).sort(); + expect(all).toEqual(["tok-1:carol@x.com", "tok-2:bob@x.com"]); + }); }); } diff --git a/src/unit-test/store-postgres-skeleton.test.ts b/src/unit-test/store-postgres-skeleton.test.ts index 84509771..45e2cb4f 100644 --- a/src/unit-test/store-postgres-skeleton.test.ts +++ b/src/unit-test/store-postgres-skeleton.test.ts @@ -52,4 +52,10 @@ describe("PostgresStore skeleton — every method rejects (not implemented)", () await expect(s.drainPending("ag")).rejects.toThrow(/not implemented/); await expect(s.close()).rejects.toThrow(/not implemented/); }); + + test("auth-token methods reject", async () => { + await expect(s.issueToken("tok", "id")).rejects.toThrow(/not implemented/); + await expect(s.resolveToken("tok")).rejects.toThrow(/not implemented/); + await expect(s.listTokens()).rejects.toThrow(/not implemented/); + }); }); diff --git a/src/unit-test/store-psk-identity-provider.test.ts b/src/unit-test/store-psk-identity-provider.test.ts new file mode 100644 index 00000000..a4ecc2c3 --- /dev/null +++ b/src/unit-test/store-psk-identity-provider.test.ts @@ -0,0 +1,38 @@ +import { describe, test, expect } from "bun:test"; +import { InMemoryStore } from "../backbone/store/memory-store"; +import { IdentityService } from "../backbone/identity-service"; +import { StorePskIdentityProvider } from "../backbone/identity/store-psk-identity-provider"; +import { runIdentityProviderContract } from "./identity-contract"; + +// Pre-seed a store with one issued token so the shared IdentityProvider contract +// (which calls makeProvider().authenticate(validCredential) synchronously) has a +// resolvable credential. Top-level await runs before the contract's tests register. +const store = new InMemoryStore(); +const svc = new IdentityService(store); +await svc.registerIdentity("alice@x.com", "Alice"); +const VALID_TOKEN = await svc.issueToken("alice@x.com"); + +runIdentityProviderContract("store-psk", { + makeProvider: () => new StorePskIdentityProvider(store), + validCredential: VALID_TOKEN, + expected: { id: "alice@x.com", displayName: "Alice" }, + invalidCredential: "not-a-real-token", +}); + +describe("StorePskIdentityProvider — Store-backed token resolution", () => { + test("a token issued AFTER construction still authenticates (reads live Store)", async () => { + const s = new InMemoryStore(); + const provider = new StorePskIdentityProvider(s); // built before any token exists + const service = new IdentityService(s); + await service.registerIdentity("bob@x.com", "Bob"); + const token = await service.issueToken("bob@x.com"); + expect(await provider.authenticate(token)).toEqual({ id: "bob@x.com", displayName: "Bob" }); + }); + + test("rejects a token that resolves to no identity row", async () => { + const s = new InMemoryStore(); + await s.issueToken("orphan-tok", "ghost@x.com"); // binding exists, identity does not + const provider = new StorePskIdentityProvider(s); + await expect(provider.authenticate("orphan-tok")).rejects.toThrow(); + }); +});