From b974e10843ab9bd067794d3e152b3000b9ebd12f Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 15 Jul 2026 03:59:32 -0700 Subject: [PATCH] feat(auth): persist the GitHub user-to-server token from login Closes #6114 completeGitHubWebOAuth/pollGitHubDeviceFlow fetched a real GitHub user-to-server token during login, used it once to verify identity via GET /user, then discarded it -- so a CLI/AMS process had no way to authenticate git operations without a separately-configured GITHUB_TOKEN PAT set up outside loopover-mcp login entirely. Persist the token encrypted at rest (AES-256-GCM, same envelope as the existing BYOK/Linear key stores) in a new isolated auth_session_github_tokens table -- kept off the auth_sessions row itself so the session lookup used on every authenticated request never touches it. Expose it via a new session-scoped POST /v1/auth/github/token endpoint (never reachable by the static mcp/api identities, never cached, never bundled into any existing login response). revokeSession now deletes the stored token too, not just the loopover session. Missing TOKEN_ENCRYPTION_SECRET warns and continues rather than blocking login -- unlike the BYOK/Linear key stores there is no re-mint-on-demand fallback for a user's own OAuth token, so a silent skip would otherwise be undiagnosable later when AMS's token fetch comes up empty. --- apps/loopover-ui/public/openapi.json | 42 +++++ .../0153_auth_session_github_tokens.sql | 17 ++ src/api/routes.ts | 16 ++ src/auth/github-oauth.ts | 4 +- src/auth/security.ts | 7 +- src/db/repositories.ts | 57 ++++++ src/db/schema.ts | 14 ++ src/openapi/spec.ts | 13 +- test/unit/auth-github-token.test.ts | 173 ++++++++++++++++++ 9 files changed, 340 insertions(+), 3 deletions(-) create mode 100644 migrations/0153_auth_session_github_tokens.sql create mode 100644 test/unit/auth-github-token.test.ts diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index ebf818b5c8..5a84eb566c 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -17896,6 +17896,48 @@ ], "summary": "Import a bounty snapshot" } + }, + "/v1/auth/github/token": { + "post": { + "summary": "Fetch the current session's live GitHub token (for AMS git operations)", + "responses": { + "200": { + "description": "The session's GitHub token", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + }, + "required": [ + "token" + ] + } + } + } + }, + "403": { + "description": "A browser session is required" + }, + "404": { + "description": "No GitHub token is available for this session" + }, + "429": { + "description": "Rate limited" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } } }, "servers": [ diff --git a/migrations/0153_auth_session_github_tokens.sql b/migrations/0153_auth_session_github_tokens.sql new file mode 100644 index 0000000000..1fef1d8580 --- /dev/null +++ b/migrations/0153_auth_session_github_tokens.sql @@ -0,0 +1,17 @@ +-- Persist the GitHub user-to-server token minted during login (#6114), encrypted at rest with AES-256-GCM +-- (see src/utils/crypto.ts). Previously this token was fetched, used once to verify identity, then discarded -- +-- so a CLI/AMS process had no way to authenticate git operations without a separately-configured GITHUB_TOKEN +-- PAT. Isolated in its own table (mirroring repository_ai_keys/repository_linear_keys' pattern, see +-- migrations/0027_repository_ai_keys.sql) rather than a column on auth_sessions itself, so the main session +-- lookup (used on every authenticated request) never touches the encrypted token, and a future bug that +-- serializes a full auth_sessions row can't leak it. +CREATE TABLE IF NOT EXISTS auth_session_github_tokens ( + session_id TEXT PRIMARY KEY, + ciphertext TEXT NOT NULL, + iv TEXT NOT NULL, + salt TEXT, + key_version INTEGER NOT NULL DEFAULT 2, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (session_id) REFERENCES auth_sessions (id) +); diff --git a/src/api/routes.ts b/src/api/routes.ts index ed4c5ebc94..2945e85f4b 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -113,6 +113,7 @@ import { deleteRepositoryLinearKey, getGlobalAgentFrozenState, setGlobalAgentFrozen, + getDecryptedSessionGitHubToken, } from "../db/repositories"; import { dedupeSignalSnapshots, pruneExpiredRecords, RETENTION_POLICY } from "../db/retention"; import { @@ -1209,6 +1210,21 @@ export function createApp() { return c.json(await buildSessionResponse(c.env, identity)); }); + // #6114: fetch the calling session's live GitHub token (persisted at login) so a CLI/AMS process can + // authenticate git operations without a separately-configured GITHUB_TOKEN PAT. Session-only (mirrors + // /v1/auth/extension/session's identity gate below) -- the static "mcp"/"api" shared-secret identities + // never reach this, since they don't represent one logged-in GitHub user's own credential. Never cached + // (this is live credential material) and never included in product-usage metadata or audit events. + app.post("/v1/auth/github/token", async (c) => { + const identity = await authenticateRequestIdentity(c); + if (!identity || identity.kind !== "session") return c.json({ error: "browser_session_required" }, 403); + const token = await getDecryptedSessionGitHubToken(c.env, identity.session.id); + c.header("Cache-Control", "no-store"); + if (!token) return c.json({ error: "github_token_unavailable" }, 404); + await recordRouteProductUsage(c, { surface: "api", eventName: "github_token_fetched", actor: identity.actor, outcome: "success" }); + return c.json({ token }); + }); + app.post("/v1/auth/logout", async (c) => { const identity = await authenticateRequestIdentity(c); const revoked = await revokeSession(c.env, identity); diff --git a/src/auth/github-oauth.ts b/src/auth/github-oauth.ts index b0b6efc0a7..6f08344f38 100644 --- a/src/auth/github-oauth.ts +++ b/src/auth/github-oauth.ts @@ -198,7 +198,9 @@ export async function createSessionFromGitHubToken( } const scopes = Array.isArray(metadata.scopes) ? metadata.scopes.filter((scope): scope is string => typeof scope === "string") : []; const githubUser = user.id === undefined ? { login: user.login } : { login: user.login, id: user.id }; - const { token, session } = await createSessionForGitHubUser(env, githubUser, { scopes, metadata }); + // #6114: the caller already just used `githubToken` for the identity check above -- pass it through so + // it's persisted for later AMS git-operation use, instead of discarding it once identity is confirmed. + const { token, session } = await createSessionForGitHubUser(env, githubUser, { scopes, metadata, githubToken }); return { token, login: session.login, expiresAt: session.expiresAt, scopes: session.scopes }; } diff --git a/src/auth/security.ts b/src/auth/security.ts index 117055d134..fbc956ee7e 100644 --- a/src/auth/security.ts +++ b/src/auth/security.ts @@ -3,6 +3,7 @@ import { getAuthSessionByTokenHash, recordAuditEvent, revokeAuthSession, + storeSessionGitHubToken, touchAuthSession, } from "../db/repositories"; import type { AuthSessionRecord, JsonValue } from "../types"; @@ -229,7 +230,10 @@ function shouldUseSecureCookie(requestUrl: string): boolean { export async function createSessionForGitHubUser( env: Env, user: { login: string; id?: number | null }, - options: { scopes?: string[]; metadata?: Record } = {}, + // `githubToken` (#6114): the raw GitHub user-to-server token this session's login exchange minted, if any. + // Persisted encrypted so a CLI/AMS process can fetch it later (see storeSessionGitHubToken) -- NEVER placed + // in `metadata` (that's a plaintext JSON blob) or otherwise logged/audited alongside this session. + options: { scopes?: string[]; metadata?: Record; githubToken?: string } = {}, ): Promise<{ token: string; session: AuthSessionRecord }> { const token = createOpaqueToken(); const issuedAt = nowIso(); @@ -246,6 +250,7 @@ export async function createSessionForGitHubUser( metadata: options.metadata ?? {}, }; await createAuthSession(env, session); + if (options.githubToken) await storeSessionGitHubToken(env, session.id, options.githubToken); await recordAuditEvent(env, { eventType: "auth.session_created", actor: user.login, diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 82d41bbb40..9dfd81434c 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -10,6 +10,7 @@ import { agentRecommendationOutcomes, agentRuns, auditEvents, + authSessionGithubTokens, authSessions, bounties, bountyLifecycleEvents, @@ -1809,6 +1810,62 @@ export async function touchAuthSession(env: Env, sessionId: string): Promise { const db = getDb(env.DB); await db.update(authSessions).set({ revokedAt: nowIso(), lastSeenAt: nowIso() }).where(eq(authSessions.id, sessionId)); + await deleteSessionGitHubToken(env, sessionId); +} + +// ─── Session-scoped GitHub token (#6114) ──────────────────────────────────────────────────────── +// The GitHub user-to-server token minted at login, persisted encrypted so a CLI/AMS process can fetch it +// on demand instead of needing a separately-configured GITHUB_TOKEN PAT. Same isolated-table, +// encrypted-at-rest shape as repositoryAiKeys/repositoryLinearKeys above (reuses TOKEN_ENCRYPTION_SECRET + +// encryptSecret/decryptSecret) -- never serialized by the session/auth GET surfaces, only ever readable via +// getDecryptedSessionGitHubToken. + +/** + * Persist a session's live GitHub token, encrypted at rest. Best-effort: unlike the BYOK/Linear key stores, + * this must never block session creation (ORB/MCP login must keep working even when a self-hoster hasn't + * configured TOKEN_ENCRYPTION_SECRET) -- absence of the key is warned about, not thrown, so the gap is + * visible/alertable rather than silently unrecoverable (there is no "re-mint on demand" fallback for a + * user's own OAuth token the way src/orb/broker.ts has for installation tokens). + */ +export async function storeSessionGitHubToken(env: Env, sessionId: string, token: string): Promise { + const secret = env.TOKEN_ENCRYPTION_SECRET; + if (!secret) { + console.warn(JSON.stringify({ level: "warn", event: "session_github_token_persist_skipped", sessionId, message: "TOKEN_ENCRYPTION_SECRET is not set; the session's GitHub token was not persisted. AMS git operations for this session will fall back to a manually-configured GITHUB_TOKEN." })); + return; + } + const { ciphertext, iv, salt, version } = await encryptSecret(token, secret); + const updatedAt = nowIso(); + const db = getDb(env.DB); + await db + .insert(authSessionGithubTokens) + .values({ sessionId, ciphertext, iv, salt, keyVersion: version, updatedAt }) + .onConflictDoUpdate({ target: authSessionGithubTokens.sessionId, set: { ciphertext, iv, salt, keyVersion: version, updatedAt } }); +} + +/** + * Decrypt a session's stored GitHub token. Returns null when no key is configured OR none was ever stored + * (e.g. TOKEN_ENCRYPTION_SECRET was unset at login time) OR decryption fails (e.g. a rotated encryption key) -- + * so a misconfiguration or a session that predates this feature never crashes the caller, only degrades to + * "unavailable, fall back to a manual PAT." + */ +export async function getDecryptedSessionGitHubToken(env: Env, sessionId: string): Promise { + const secret = env.TOKEN_ENCRYPTION_SECRET; + if (!secret) return null; + const db = getDb(env.DB); + const [row] = await db.select().from(authSessionGithubTokens).where(eq(authSessionGithubTokens.sessionId, sessionId)).limit(1); + if (!row) return null; + try { + return await decryptSecret(row.ciphertext, row.iv, secret, row.salt); + } catch { + return null; + } +} + +/** Delete a session's stored GitHub token. Called from revokeAuthSession so logout/revocation removes the + * credential too, not just the loopover session. No-op (not an error) when none was ever stored. */ +export async function deleteSessionGitHubToken(env: Env, sessionId: string): Promise { + const db = getDb(env.DB); + await db.delete(authSessionGithubTokens).where(eq(authSessionGithubTokens.sessionId, sessionId)); } export async function countActiveAuthSessions(env: Env): Promise { diff --git a/src/db/schema.ts b/src/db/schema.ts index bda6c6765e..87003be757 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1143,6 +1143,20 @@ export const authSessions = sqliteTable( }), ); +// The GitHub user-to-server token minted at login (#6114), encrypted at rest -- same AES-256-GCM envelope as +// repositoryAiKeys/repositoryLinearKeys above (src/utils/crypto.ts), isolated in its own table for the same +// reason: the main auth_sessions lookup (every authenticated request) never touches this column, so it can't +// leak via a future bug that serializes a full session row. One row per session; deleted on revocation. +export const authSessionGithubTokens = sqliteTable("auth_session_github_tokens", { + sessionId: text("session_id").primaryKey(), + ciphertext: text("ciphertext").notNull(), + iv: text("iv").notNull(), + salt: text("salt"), + keyVersion: integer("key_version").notNull().default(2), + createdAt: text("created_at").notNull().$defaultFn(() => nowIso()), + updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()), +}); + export const digestSubscriptions = sqliteTable( "digest_subscriptions", { diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 8fde005f59..a10323935f 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -897,6 +897,17 @@ export function buildOpenApiSpec() { 200: { description: "Current auth session, or signed_out when no app session is present" }, }, }); + registry.registerPath({ + method: "post", + path: "/v1/auth/github/token", + summary: "Fetch the current session's live GitHub token (for AMS git operations)", + responses: { + 200: { description: "The session's GitHub token", content: { "application/json": { schema: z.object({ token: z.string() }) } } }, + 403: { description: "A browser session is required" }, + 404: { description: "No GitHub token is available for this session" }, + 429: { description: "Rate limited" }, + }, + }); registry.registerPath({ method: "get", path: "/v1/app/overview", @@ -1242,7 +1253,7 @@ function applySecurityMetadata(document: GeneratedOpenApiDocument): GeneratedOpe function isProtectedPath(path: string): boolean { if (path === "/health" || path === "/openapi.json" || path === "/mcp" || path === "/v1/mcp/compatibility" || path === "/v1/public/stats" || path === "/v1/public/github/repos/{owner}/{repo}/stats" || path === "/v1/public/repos/{owner}/{repo}/quality") return false; - if (path.startsWith("/v1/auth/")) return path === "/v1/auth/extension/session"; + if (path.startsWith("/v1/auth/")) return path === "/v1/auth/extension/session" || path === "/v1/auth/github/token"; if (path === "/v1/github/webhook") return false; return path.startsWith("/v1/"); } diff --git a/test/unit/auth-github-token.test.ts b/test/unit/auth-github-token.test.ts new file mode 100644 index 0000000000..27f494c525 --- /dev/null +++ b/test/unit/auth-github-token.test.ts @@ -0,0 +1,173 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { completeGitHubWebOAuth, pollGitHubDeviceFlow, startGitHubWebOAuth } from "../../src/auth/github-oauth"; +import { authenticatePrivateToken, createSessionForGitHubUser, revokeSession } from "../../src/auth/security"; +import { deleteSessionGitHubToken, getDecryptedSessionGitHubToken, storeSessionGitHubToken } from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; + +const SECRET = "example-unit-test-encryption-secret-32-bytes-long"; + +describe("session GitHub token storage (#6114)", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("stores an encrypted token at session creation and decrypts it at call time", async () => { + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + const { session } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 42 }, { githubToken: "gh-token-abc123" }); + await expect(getDecryptedSessionGitHubToken(env, session.id)).resolves.toBe("gh-token-abc123"); + + // The persisted row stores ciphertext, never the plaintext token. + const row = await env.DB.prepare("select ciphertext, iv from auth_session_github_tokens where session_id = ?").bind(session.id).first<{ ciphertext: string; iv: string }>(); + expect(row?.ciphertext).not.toContain("gh-token-abc123"); + }); + + it("does not persist anything when no githubToken is supplied (ordinary sessions unaffected)", async () => { + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + const { session } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 42 }); + await expect(getDecryptedSessionGitHubToken(env, session.id)).resolves.toBeNull(); + const row = await env.DB.prepare("select session_id from auth_session_github_tokens where session_id = ?").bind(session.id).first(); + expect(row ?? null).toBeNull(); + }); + + it("REGRESSION: session creation still succeeds when TOKEN_ENCRYPTION_SECRET is unset, warning instead of throwing (#6114 -- unlike BYOK/Linear keys, there is no re-mint fallback for a user's own OAuth token)", async () => { + const env = createTestEnv({}); + const warnings = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const { token, session } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 42 }, { githubToken: "gh-token-xyz" }); + expect(token).toBeTruthy(); // the loopover session itself was still created + await expect(getDecryptedSessionGitHubToken(env, session.id)).resolves.toBeNull(); + expect(warnings.mock.calls.some(([line]) => String(line).includes("session_github_token_persist_skipped") && String(line).includes(session.id))).toBe(true); + warnings.mockRestore(); + }); + + it("returns null (not throw) when decrypting with a rotated/wrong encryption secret", async () => { + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + const { session } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 42 }, { githubToken: "gh-token-abc123" }); + const wrongSecretEnv = { ...env, TOKEN_ENCRYPTION_SECRET: "totally-different-example-secret-32-bytes-min" } as unknown as Env; + await expect(getDecryptedSessionGitHubToken(wrongSecretEnv, session.id)).resolves.toBeNull(); + const noSecretEnv = { ...env, TOKEN_ENCRYPTION_SECRET: undefined } as unknown as Env; + await expect(getDecryptedSessionGitHubToken(noSecretEnv, session.id)).resolves.toBeNull(); + }); + + it("returns null for a session id that was never stored at all", async () => { + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + await expect(getDecryptedSessionGitHubToken(env, "nonexistent-session-id")).resolves.toBeNull(); + }); + + it("replaces the stored token on re-authentication, not append/duplicate", async () => { + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + const { session } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 42 }, { githubToken: "first-token" }); + await storeSessionGitHubToken(env, session.id, "second-token"); + await expect(getDecryptedSessionGitHubToken(env, session.id)).resolves.toBe("second-token"); + const count = await env.DB.prepare("select count(*) as n from auth_session_github_tokens where session_id = ?").bind(session.id).first<{ n: number }>(); + expect(count?.n).toBe(1); + }); + + it("revokeSession deletes the stored GitHub token, not just the loopover session", async () => { + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + const { token, session } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 42 }, { githubToken: "gh-token-abc123" }); + const identity = await authenticatePrivateToken(env, token); + await revokeSession(env, identity); + await expect(getDecryptedSessionGitHubToken(env, session.id)).resolves.toBeNull(); + const row = await env.DB.prepare("select session_id from auth_session_github_tokens where session_id = ?").bind(session.id).first(); + expect(row ?? null).toBeNull(); + }); + + it("deleteSessionGitHubToken is a no-op (not an error) when nothing was ever stored", async () => { + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + await expect(deleteSessionGitHubToken(env, "nonexistent-session-id")).resolves.toBeUndefined(); + }); + + it("never appears in plaintext in the session-creation audit event", async () => { + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + await createSessionForGitHubUser(env, { login: "jsonbored", id: 42 }, { githubToken: "super-secret-gh-token-value" }); + const events = await env.DB.prepare("select metadata_json, detail from audit_events where event_type = ?").bind("auth.session_created").all<{ metadata_json: string; detail: string | null }>(); + expect(JSON.stringify(events.results)).not.toContain("super-secret-gh-token-value"); + }); + + it("the device-flow login persists the token end-to-end (not just the isolated repository function)", async () => { + const env = createTestEnv({ GITHUB_OAUTH_CLIENT_ID: "client-id", TOKEN_ENCRYPTION_SECRET: SECRET }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("access_token")) return Response.json({ access_token: "device-flow-gh-token", scope: "read:user" }); + if (url === "https://api.github.com/user") return Response.json({ login: "jsonbored", id: 42 }); + return Response.json({}); + }); + const result = await pollGitHubDeviceFlow(env, "device-code"); + if (!("token" in result)) throw new Error("expected an authenticated session result"); + const identity = await authenticatePrivateToken(env, result.token); + if (identity?.kind !== "session") throw new Error("expected a session identity"); + await expect(getDecryptedSessionGitHubToken(env, identity.session.id)).resolves.toBe("device-flow-gh-token"); + }); + + it("the web-OAuth login persists the token end-to-end", async () => { + const env = createTestEnv({ GITHUB_OAUTH_CLIENT_ID: "client-id", GITHUB_OAUTH_CLIENT_SECRET: "client-secret", TOKEN_ENCRYPTION_SECRET: SECRET }); + const started = await startGitHubWebOAuth(env, "https://api.example/v1/auth/github/start", undefined); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("access_token")) return Response.json({ access_token: "web-oauth-gh-token", scope: "read:user" }); + if (url === "https://api.github.com/user") return Response.json({ login: "jsonbored", id: 42 }); + return Response.json({}); + }); + const session = await completeGitHubWebOAuth(env, "https://api.example/v1/auth/github/callback", { + code: "code", + state: started.state, + cookieState: started.state, + }); + const identity = await authenticatePrivateToken(env, session.token); + if (identity?.kind !== "session") throw new Error("expected a session identity"); + await expect(getDecryptedSessionGitHubToken(env, identity.session.id)).resolves.toBe("web-oauth-gh-token"); + }); +}); + +describe("POST /v1/auth/github/token route (#6114)", () => { + it("returns the session's live GitHub token, never cached", async () => { + const app = createApp(); + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + const { token } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 42 }, { githubToken: "gh-token-abc123" }); + const res = await app.request("/v1/auth/github/token", { method: "POST", headers: { cookie: `loopover_session=${token}` } }, env); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ token: "gh-token-abc123" }); + expect(res.headers.get("cache-control")).toBe("no-store"); + }); + + it("returns 404 github_token_unavailable when no token was persisted for this session", async () => { + const app = createApp(); + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + const { token } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 42 }); + const res = await app.request("/v1/auth/github/token", { method: "POST", headers: { cookie: `loopover_session=${token}` } }, env); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: "github_token_unavailable" }); + expect(res.headers.get("cache-control")).toBe("no-store"); + }); + + it("rejects unauthenticated access", async () => { + const app = createApp(); + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + const res = await app.request("/v1/auth/github/token", { method: "POST" }, env); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: "browser_session_required" }); + }); + + it("rejects the static mcp/api shared-secret identities -- session-only, since they represent no single logged-in GitHub user", async () => { + const app = createApp(); + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + const apiRes = await app.request("/v1/auth/github/token", { method: "POST", headers: { authorization: `Bearer ${env.LOOPOVER_API_TOKEN}` } }, env); + expect(apiRes.status).toBe(403); + const mcpRes = await app.request("/v1/auth/github/token", { method: "POST", headers: { authorization: `Bearer ${env.LOOPOVER_MCP_TOKEN}` } }, env); + expect(mcpRes.status).toBe(403); + }); + + it("records product-usage telemetry without ever including the token", async () => { + const app = createApp(); + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + const { token } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 42 }, { githubToken: "super-secret-value" }); + const res = await app.request( + "/v1/auth/github/token", + { method: "POST", headers: { cookie: `loopover_session=${token}`, "x-loopover-mcp-client": "test" } }, + env, + ); + expect(res.status).toBe(200); + const events = await env.DB.prepare("select * from product_usage_events where event_name = ?").bind("github_token_fetched").all(); + expect(events.results.length).toBeGreaterThan(0); + expect(JSON.stringify(events.results)).not.toContain("super-secret-value"); + }); +});