diff --git a/migrations/0155_auth_session_github_token_refresh.sql b/migrations/0155_auth_session_github_token_refresh.sql new file mode 100644 index 0000000000..7ffad6ed71 --- /dev/null +++ b/migrations/0155_auth_session_github_token_refresh.sql @@ -0,0 +1,15 @@ +-- Refresh/expiration for the session GitHub token (#6115). GitHub App user-to-server tokens expire 8h after +-- issue by default (a `refresh_token` valid 6 months is issued alongside, unless the App owner opted OUT of +-- token expiration entirely -- see GitHub's own docs: "Refreshing user access tokens"). AMS runs can outlive +-- 8h, so the stored access token alone (added in #6114 / migrations/0153) isn't enough on its own for a +-- long-running session. All columns are nullable: existing #6114 rows predate this migration (no expiry/refresh +-- info was ever captured for them), and even a fresh row may have no refresh_token if a specific token-exchange +-- response never included one (e.g. the /v1/auth/github/session caller-supplied-token path, which never went +-- through our own device/web OAuth exchange) -- getLiveSessionGitHubToken (src/auth/github-oauth.ts) treats an +-- absent expires_at as "never expires" for backward compatibility with those rows. +ALTER TABLE auth_session_github_tokens ADD COLUMN expires_at TEXT; +ALTER TABLE auth_session_github_tokens ADD COLUMN refresh_ciphertext TEXT; +ALTER TABLE auth_session_github_tokens ADD COLUMN refresh_iv TEXT; +ALTER TABLE auth_session_github_tokens ADD COLUMN refresh_salt TEXT; +ALTER TABLE auth_session_github_tokens ADD COLUMN refresh_key_version INTEGER; +ALTER TABLE auth_session_github_tokens ADD COLUMN refresh_expires_at TEXT; diff --git a/src/api/routes.ts b/src/api/routes.ts index 2945e85f4b..caa3a0c854 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -3,7 +3,7 @@ import { sentry } from "@sentry/hono/cloudflare"; import { z } from "zod"; import { parsePositiveInt } from "../utils/json"; import { analyzePRQueue, type AuthorRole, type ChecksStatus } from "../queue-intelligence"; -import { completeGitHubWebOAuth, createSessionFromGitHubToken, pollGitHubDeviceFlow, startGitHubDeviceFlow, startGitHubWebOAuth } from "../auth/github-oauth"; +import { completeGitHubWebOAuth, createSessionFromGitHubToken, getLiveSessionGitHubToken, pollGitHubDeviceFlow, startGitHubDeviceFlow, startGitHubWebOAuth } from "../auth/github-oauth"; import { enforceRateLimit, routeClassForPath } from "../auth/rate-limit"; import { handleShot } from "../review/visual/shot"; import { isScreenshotsEnabled } from "../review/visual-wire"; @@ -113,7 +113,6 @@ import { deleteRepositoryLinearKey, getGlobalAgentFrozenState, setGlobalAgentFrozen, - getDecryptedSessionGitHubToken, } from "../db/repositories"; import { dedupeSignalSnapshots, pruneExpiredRecords, RETENTION_POLICY } from "../db/retention"; import { @@ -1210,15 +1209,16 @@ 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 + // #6114/#6115: fetch the calling session's live GitHub token (persisted at login, transparently refreshed + // near/past its 8h expiry via getLiveSessionGitHubToken) 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); + const token = await getLiveSessionGitHubToken(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" }); diff --git a/src/auth/github-oauth.ts b/src/auth/github-oauth.ts index 6f08344f38..ce26cd7460 100644 --- a/src/auth/github-oauth.ts +++ b/src/auth/github-oauth.ts @@ -4,7 +4,7 @@ import { createSessionForGitHubUser, timingSafeEqual, } from "./security"; -import { recordAuditEvent } from "../db/repositories"; +import { getDecryptedSessionGitHubTokenBundle, recordAuditEvent, storeSessionGitHubToken } from "../db/repositories"; import { timeoutFetch } from "../github/client"; import type { JsonValue } from "../types"; @@ -16,8 +16,12 @@ type GitHubDeviceCodeResponse = { interval?: number; }; +// `expires_in`/`refresh_token`/`refresh_token_expires_in` (#6115) are only present when the App owner has +// user-to-server token expiration enabled -- the default for a GitHub App unless explicitly opted out +// (GitHub's own docs: "Refreshing user access tokens"). Absent when expiration is disabled, so every reader +// of these fields must treat them as optional, not assume presence. type GitHubAccessTokenResponse = - | { access_token: string; token_type?: string; scope?: string } + | { access_token: string; token_type?: string; scope?: string; expires_in?: number; refresh_token?: string; refresh_token_expires_in?: number } | { error: string; error_description?: string }; type GitHubUserResponse = { @@ -90,10 +94,13 @@ export async function pollGitHubDeviceFlow(env: Env, deviceCode: string) { }; } if (!tokenPayload.access_token) throw new Error("github_access_token_missing"); - return createSessionFromGitHubToken(env, tokenPayload.access_token, { - source: "github_device_flow", - scopes: parseScopes(tokenPayload.scope), - }); + const lifecycle = tokenLifecycleFromResponse(tokenPayload); + return createSessionFromGitHubToken( + env, + tokenPayload.access_token, + { source: "github_device_flow", scopes: parseScopes(tokenPayload.scope) }, + { tokenExpiresAt: lifecycle.expiresAt, refreshToken: lifecycle.refreshToken, refreshTokenExpiresAt: lifecycle.refreshExpiresAt }, + ); } export async function startGitHubWebOAuth( @@ -149,11 +156,13 @@ export async function completeGitHubWebOAuth( throw new Error("error" in tokenPayload ? (tokenPayload.error_description ?? tokenPayload.error) : "github_oauth_token_exchange_failed"); } if (!tokenPayload.access_token) throw new Error("github_access_token_missing"); - const session = await createSessionFromGitHubToken(env, tokenPayload.access_token, { - source: "github_web_oauth", - stateNonce: state.nonce, - scopes: parseScopes(tokenPayload.scope), - }); + const lifecycle = tokenLifecycleFromResponse(tokenPayload); + const session = await createSessionFromGitHubToken( + env, + tokenPayload.access_token, + { source: "github_web_oauth", stateNonce: state.nonce, scopes: parseScopes(tokenPayload.scope) }, + { tokenExpiresAt: lifecycle.expiresAt, refreshToken: lifecycle.refreshToken, refreshTokenExpiresAt: lifecycle.refreshExpiresAt }, + ); await recordAuditEvent(env, { eventType: "auth.github_web_callback", actor: session.login, @@ -166,7 +175,10 @@ export async function createSessionFromGitHubToken( env: Env, githubToken: string, metadata: Record = {}, - options: { verifyAppAudience?: boolean } = {}, + // `tokenExpiresAt`/`refreshToken`/`refreshTokenExpiresAt` (#6115): only known when the caller (the device/web + // OAuth flows below) minted `githubToken` itself via our own exchange -- absent for a caller-supplied token + // (the /v1/auth/github/session route), which has no lifecycle info to offer. + options: { verifyAppAudience?: boolean; tokenExpiresAt?: string | null; refreshToken?: string | null; refreshTokenExpiresAt?: string | null } = {}, ): Promise<{ token: string; login: string; expiresAt: string; scopes: string[] }> { // A caller-supplied token (the github_token_exchange route) carries no proof it was minted for THIS // OAuth app. Without an audience check, any token a victim issued to an unrelated app would mint a @@ -200,7 +212,14 @@ export async function createSessionFromGitHubToken( const githubUser = user.id === undefined ? { login: user.login } : { login: user.login, id: user.id }; // #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 }); + const { token, session } = await createSessionForGitHubUser(env, githubUser, { + scopes, + metadata, + githubToken, + githubTokenExpiresAt: options.tokenExpiresAt, + githubRefreshToken: options.refreshToken, + githubRefreshTokenExpiresAt: options.refreshTokenExpiresAt, + }); return { token, login: session.login, expiresAt: session.expiresAt, scopes: session.scopes }; } @@ -232,6 +251,93 @@ function parseScopes(scopeHeader: string | undefined): string[] { .filter(Boolean); } +// #6115: turn a raw GitHub token-exchange response's expires_in/refresh_token/refresh_token_expires_in +// (relative seconds-from-now, when present at all) into the absolute ISO timestamps this codebase's own +// convention stores everywhere else (mirrors src/orb/broker.ts's own minted.expiresAt shape). +function tokenLifecycleFromResponse(payload: { expires_in?: number; refresh_token?: string; refresh_token_expires_in?: number }): { + expiresAt: string | null; + refreshToken: string | null; + refreshExpiresAt: string | null; +} { + return { + expiresAt: typeof payload.expires_in === "number" ? new Date(Date.now() + payload.expires_in * 1000).toISOString() : null, + refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : null, + refreshExpiresAt: typeof payload.refresh_token_expires_in === "number" ? new Date(Date.now() + payload.refresh_token_expires_in * 1000).toISOString() : null, + }; +} + +// A stored access token is refreshed once it has less than this much time left, not right at the edge -- +// AMS's own token-resolution (#6116) fetches once per process start and caches in memory for that process's +// lifetime, so a request landing with only seconds of headroom would otherwise fail mid-use. Generous relative +// to the 8h default lifetime; the cost is at most one extra GitHub round-trip per near-expiry fetch. +const GITHUB_TOKEN_REFRESH_MARGIN_MS = 15 * 60_000; + +/** + * Resolve a currently-LIVE GitHub token for a session, transparently refreshing via the stored refresh_token + * when the access token is near/past expiry (#6115). Falls back to the (possibly stale) access token as-is + * when there's no expiry on record (a #6114-era row, or an exchange that never returned expires_in -- treated + * as "never expires" for backward compatibility) or no refresh_token is available. Returns null when nothing + * usable remains: no token was ever stored, decryption fails, the refresh token itself is expired, or the + * refresh attempt fails and a concurrent request's own refresh (rotating the SAME refresh token, per GitHub's + * one-time-use-then-rotate contract) hasn't landed either -- callers already treat a null token as + * "unavailable, fall back to a manual PAT." + */ +export async function getLiveSessionGitHubToken(env: Env, sessionId: string): Promise { + const bundle = await getDecryptedSessionGitHubTokenBundle(env, sessionId); + if (!bundle) return null; + + const expiresAtMs = bundle.expiresAt ? Date.parse(bundle.expiresAt) : NaN; + const hasKnownExpiry = Number.isFinite(expiresAtMs); + if (!hasKnownExpiry || expiresAtMs - Date.now() >= GITHUB_TOKEN_REFRESH_MARGIN_MS) return bundle.accessToken; + + if (!bundle.refreshToken) return bundle.accessToken; // near/past expiry, but nothing to refresh WITH -- best effort. + const refreshExpiresAtMs = bundle.refreshExpiresAt ? Date.parse(bundle.refreshExpiresAt) : NaN; + if (Number.isFinite(refreshExpiresAtMs) && refreshExpiresAtMs <= Date.now()) return null; // dead end: re-login required. + + try { + const refreshed = await refreshGitHubUserToken(env, bundle.refreshToken); + await storeSessionGitHubToken(env, sessionId, refreshed.accessToken, { + expiresAt: refreshed.expiresAt, + refreshToken: refreshed.refreshToken, + refreshExpiresAt: refreshed.refreshExpiresAt, + }); + return refreshed.accessToken; + } catch { + // The refresh token GitHub issues is single-use-then-rotated: a concurrent request racing this one may + // have already refreshed (consuming the same refresh token this attempt just failed with). Re-read once + // rather than fail outright -- if the OTHER request's refresh already landed, its result is exactly as + // usable as if this call had won the race itself. + const retried = await getDecryptedSessionGitHubTokenBundle(env, sessionId); + return retried && retried.accessToken !== bundle.accessToken ? retried.accessToken : null; + } +} + +/** Exchange a session's stored refresh_token for a fresh access token (#6115). Mirrors the initial + * code/device-code exchanges below -- same endpoint, `grant_type: "refresh_token"` instead. */ +async function refreshGitHubUserToken( + env: Env, + refreshToken: string, +): Promise<{ accessToken: string; expiresAt: string | null; refreshToken: string | null; refreshExpiresAt: string | null }> { + if (!env.GITHUB_OAUTH_CLIENT_ID || !env.GITHUB_OAUTH_CLIENT_SECRET) throw new Error("github_oauth_not_configured"); + const response = await timeoutFetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/json", + "user-agent": "loopover-api", + }, + body: JSON.stringify({ + client_id: env.GITHUB_OAUTH_CLIENT_ID, + client_secret: env.GITHUB_OAUTH_CLIENT_SECRET, + grant_type: "refresh_token", + refresh_token: refreshToken, + }), + }); + const payload = (await response.json().catch(() => ({}))) as GitHubAccessTokenResponse; + if (!response.ok || "error" in payload || !payload.access_token) throw new Error("github_refresh_failed"); + return { accessToken: payload.access_token, ...tokenLifecycleFromResponse(payload) }; +} + function githubOAuthCallbackUrl(env: Env, requestUrl: string): string { const origin = env.PUBLIC_API_ORIGIN ?? new URL(requestUrl).origin; return `${origin.replace(/\/$/, "")}/v1/auth/github/callback`; diff --git a/src/auth/security.ts b/src/auth/security.ts index fbc956ee7e..b04d2bb278 100644 --- a/src/auth/security.ts +++ b/src/auth/security.ts @@ -233,7 +233,16 @@ export async function createSessionForGitHubUser( // `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 } = {}, + // `githubTokenExpiresAt`/`githubRefreshToken`/`githubRefreshTokenExpiresAt` (#6115): only known when the + // login exchange went through our own device/web OAuth flow -- absent for the caller-supplied-token path. + options: { + scopes?: string[]; + metadata?: Record; + githubToken?: string; + githubTokenExpiresAt?: string | null | undefined; + githubRefreshToken?: string | null | undefined; + githubRefreshTokenExpiresAt?: string | null | undefined; + } = {}, ): Promise<{ token: string; session: AuthSessionRecord }> { const token = createOpaqueToken(); const issuedAt = nowIso(); @@ -250,7 +259,13 @@ export async function createSessionForGitHubUser( metadata: options.metadata ?? {}, }; await createAuthSession(env, session); - if (options.githubToken) await storeSessionGitHubToken(env, session.id, options.githubToken); + if (options.githubToken) { + await storeSessionGitHubToken(env, session.id, options.githubToken, { + expiresAt: options.githubTokenExpiresAt, + refreshToken: options.githubRefreshToken, + refreshExpiresAt: options.githubRefreshTokenExpiresAt, + }); + } await recordAuditEvent(env, { eventType: "auth.session_created", actor: user.login, diff --git a/src/db/repositories.ts b/src/db/repositories.ts index eb2359eebe..6f59a14d33 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -1839,33 +1839,55 @@ export async function revokeAuthSession(env: Env, sessionId: string): Promise { +export async function storeSessionGitHubToken(env: Env, sessionId: string, token: string, lifecycle: SessionGitHubTokenLifecycle = {}): 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 refreshEncrypted = lifecycle.refreshToken ? await encryptSecret(lifecycle.refreshToken, secret) : null; const updatedAt = nowIso(); + const values = { + sessionId, + ciphertext, + iv, + salt, + keyVersion: version, + expiresAt: lifecycle.expiresAt ?? null, + refreshCiphertext: refreshEncrypted?.ciphertext ?? null, + refreshIv: refreshEncrypted?.iv ?? null, + refreshSalt: refreshEncrypted?.salt ?? null, + refreshKeyVersion: refreshEncrypted?.version ?? null, + refreshExpiresAt: lifecycle.refreshExpiresAt ?? null, + updatedAt, + }; 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 } }); + await db.insert(authSessionGithubTokens).values(values).onConflictDoUpdate({ target: authSessionGithubTokens.sessionId, set: values }); } /** * 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." + * "unavailable, fall back to a manual PAT." Ignores expiry -- callers that need a currently-LIVE token + * (refreshing when near/past expiry) should use getLiveSessionGitHubToken (src/auth/github-oauth.ts) instead. */ export async function getDecryptedSessionGitHubToken(env: Env, sessionId: string): Promise { const secret = env.TOKEN_ENCRYPTION_SECRET; @@ -1880,6 +1902,38 @@ export async function getDecryptedSessionGitHubToken(env: Env, sessionId: string } } +export type SessionGitHubTokenBundle = { accessToken: string; expiresAt: string | null; refreshToken: string | null; refreshExpiresAt: string | null }; + +/** + * Decrypt a session's full stored GitHub token record -- access token AND (if present) refresh token, plus + * both expiries -- for getLiveSessionGitHubToken's (#6115) refresh-when-near-expiry decision. Returns null on + * the same fail-safe conditions as getDecryptedSessionGitHubToken (no key, no row, decrypt failure). A stored + * refresh ciphertext that fails to decrypt independently degrades to `refreshToken: null` rather than failing + * the whole bundle -- the access token half may still be perfectly usable even if the refresh half is corrupt. + */ +export async function getDecryptedSessionGitHubTokenBundle(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; + let accessToken: string; + try { + accessToken = await decryptSecret(row.ciphertext, row.iv, secret, row.salt); + } catch { + return null; + } + let refreshToken: string | null = null; + if (row.refreshCiphertext && row.refreshIv) { + try { + refreshToken = await decryptSecret(row.refreshCiphertext, row.refreshIv, secret, row.refreshSalt); + } catch { + refreshToken = null; + } + } + return { accessToken, expiresAt: row.expiresAt, refreshToken, refreshExpiresAt: row.refreshExpiresAt }; +} + /** 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 { diff --git a/src/db/schema.ts b/src/db/schema.ts index 7a3077a277..f43e8e4600 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1159,6 +1159,16 @@ export const authSessionGithubTokens = sqliteTable("auth_session_github_tokens", iv: text("iv").notNull(), salt: text("salt"), keyVersion: integer("key_version").notNull().default(2), + // Access-token expiry + an optional refresh token (#6115) -- both nullable: a #6114-era row predates this + // migration, and even a fresh row may lack a refresh token if the specific exchange never returned one (see + // migrations/0154's own header). getLiveSessionGitHubToken (src/auth/github-oauth.ts) treats a null + // expiresAt as "never expires" for backward compatibility with those rows. + expiresAt: text("expires_at"), + refreshCiphertext: text("refresh_ciphertext"), + refreshIv: text("refresh_iv"), + refreshSalt: text("refresh_salt"), + refreshKeyVersion: integer("refresh_key_version"), + refreshExpiresAt: text("refresh_expires_at"), createdAt: text("created_at").notNull().$defaultFn(() => nowIso()), updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()), }); diff --git a/test/unit/auth-github-token.test.ts b/test/unit/auth-github-token.test.ts index 27f494c525..a77d6ddb04 100644 --- a/test/unit/auth-github-token.test.ts +++ b/test/unit/auth-github-token.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createApp } from "../../src/api/routes"; -import { completeGitHubWebOAuth, pollGitHubDeviceFlow, startGitHubWebOAuth } from "../../src/auth/github-oauth"; +import { completeGitHubWebOAuth, getLiveSessionGitHubToken, pollGitHubDeviceFlow, startGitHubWebOAuth } from "../../src/auth/github-oauth"; import { authenticatePrivateToken, createSessionForGitHubUser, revokeSession } from "../../src/auth/security"; -import { deleteSessionGitHubToken, getDecryptedSessionGitHubToken, storeSessionGitHubToken } from "../../src/db/repositories"; +import { deleteSessionGitHubToken, getDecryptedSessionGitHubToken, getDecryptedSessionGitHubTokenBundle, storeSessionGitHubToken } from "../../src/db/repositories"; import { createTestEnv } from "../helpers/d1"; const SECRET = "example-unit-test-encryption-secret-32-bytes-long"; @@ -118,6 +118,235 @@ describe("session GitHub token storage (#6114)", () => { }); }); +describe("session GitHub token refresh (#6115)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("captures expires_in/refresh_token/refresh_token_expires_in end-to-end when GitHub's response includes them", async () => { + const env = createTestEnv({ GITHUB_OAUTH_CLIENT_ID: "client-id", TOKEN_ENCRYPTION_SECRET: SECRET }); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-15T00:00:00.000Z")); + 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", expires_in: 28800, refresh_token: "device-flow-refresh-token", refresh_token_expires_in: 15897600 }); + } + 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"); + const bundle = await getDecryptedSessionGitHubTokenBundle(env, identity.session.id); + expect(bundle?.accessToken).toBe("device-flow-gh-token"); + expect(bundle?.expiresAt).toBe("2026-07-15T08:00:00.000Z"); // now + 28800s (8h) + expect(bundle?.refreshToken).toBe("device-flow-refresh-token"); + expect(bundle?.refreshExpiresAt).toBe("2027-01-15T00:00:00.000Z"); // now + 15897600s (~6mo) + }); + + it("returns the access token as-is when nowhere near expiry (no refresh call made)", async () => { + const env = createTestEnv({ GITHUB_OAUTH_CLIENT_ID: "client-id", GITHUB_OAUTH_CLIENT_SECRET: "client-secret", TOKEN_ENCRYPTION_SECRET: SECRET }); + const { session } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 42 }, { + githubToken: "fresh-token", + githubTokenExpiresAt: new Date(Date.now() + 8 * 60 * 60_000).toISOString(), + githubRefreshToken: "unused-refresh-token", + githubRefreshTokenExpiresAt: new Date(Date.now() + 180 * 24 * 60 * 60_000).toISOString(), + }); + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + await expect(getLiveSessionGitHubToken(env, session.id)).resolves.toBe("fresh-token"); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("refreshes when within the margin of expiry, persisting the new access + refresh tokens", async () => { + const env = createTestEnv({ GITHUB_OAUTH_CLIENT_ID: "client-id", GITHUB_OAUTH_CLIENT_SECRET: "client-secret", TOKEN_ENCRYPTION_SECRET: SECRET }); + const { session } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 42 }, { + githubToken: "near-expiry-token", + githubTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(), // 1 minute left, well inside the 15min margin + githubRefreshToken: "old-refresh-token", + githubRefreshTokenExpiresAt: new Date(Date.now() + 180 * 24 * 60 * 60_000).toISOString(), + }); + let capturedBody: unknown; + vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedBody = JSON.parse(String(init?.body ?? "{}")); + return Response.json({ access_token: "refreshed-token", expires_in: 28800, refresh_token: "new-refresh-token", refresh_token_expires_in: 15897600 }); + }); + + await expect(getLiveSessionGitHubToken(env, session.id)).resolves.toBe("refreshed-token"); + expect(capturedBody).toMatchObject({ grant_type: "refresh_token", refresh_token: "old-refresh-token", client_id: "client-id", client_secret: "client-secret" }); + + const bundle = await getDecryptedSessionGitHubTokenBundle(env, session.id); + expect(bundle?.accessToken).toBe("refreshed-token"); + expect(bundle?.refreshToken).toBe("new-refresh-token"); // rotated, not the old one left in place + }); + + it("falls back to the (possibly-stale) access token when there is nothing to refresh WITH", async () => { + const env = createTestEnv({ GITHUB_OAUTH_CLIENT_ID: "client-id", GITHUB_OAUTH_CLIENT_SECRET: "client-secret", TOKEN_ENCRYPTION_SECRET: SECRET }); + const { session } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 42 }, { + githubToken: "near-expiry-no-refresh-token", + githubTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(), + // No githubRefreshToken supplied at all. + }); + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + await expect(getLiveSessionGitHubToken(env, session.id)).resolves.toBe("near-expiry-no-refresh-token"); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("returns null (no network call) when the refresh token itself has already expired", async () => { + const env = createTestEnv({ GITHUB_OAUTH_CLIENT_ID: "client-id", GITHUB_OAUTH_CLIENT_SECRET: "client-secret", TOKEN_ENCRYPTION_SECRET: SECRET }); + const { session } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 42 }, { + githubToken: "dead-end-token", + githubTokenExpiresAt: new Date(Date.now() - 60_000).toISOString(), // already expired + githubRefreshToken: "dead-refresh-token", + githubRefreshTokenExpiresAt: new Date(Date.now() - 1000).toISOString(), // the refresh token is ALSO dead + }); + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + await expect(getLiveSessionGitHubToken(env, session.id)).resolves.toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); // no point attempting a refresh that's guaranteed to fail + }); + + it("treats an absent expiresAt as never-expiring, for backward compat with #6114-era rows", async () => { + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + const { session } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 42 }, { githubToken: "pre-6115-token" }); + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + await expect(getLiveSessionGitHubToken(env, session.id)).resolves.toBe("pre-6115-token"); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("returns null when the token was never stored at all", async () => { + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + await expect(getLiveSessionGitHubToken(env, "nonexistent-session")).resolves.toBeNull(); + }); + + it("recovers from a failed refresh when a concurrent request already rotated the same refresh token", async () => { + const env = createTestEnv({ GITHUB_OAUTH_CLIENT_ID: "client-id", GITHUB_OAUTH_CLIENT_SECRET: "client-secret", TOKEN_ENCRYPTION_SECRET: SECRET }); + const { session } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 42 }, { + githubToken: "near-expiry-token", + githubTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(), + githubRefreshToken: "already-rotated-refresh-token", + githubRefreshTokenExpiresAt: new Date(Date.now() + 180 * 24 * 60 * 60_000).toISOString(), + }); + // This attempt's own refresh call fails (GitHub rejects the now-rotated refresh token) -- but as a side + // effect of that SAME mocked call, simulate a concurrent request's own successful refresh landing in the + // DB in the window between this function's initial read (already completed, which is why fetch is being + // called at all) and its post-failure retry read. Writing the "concurrent" update inside the fetch mock + // (rather than before calling getLiveSessionGitHubToken) is what actually exercises the retry path -- + // writing it beforehand would just make the function's own INITIAL read see the fresh token directly. + vi.stubGlobal("fetch", async () => { + await storeSessionGitHubToken(env, session.id, "concurrently-refreshed-token", { + expiresAt: new Date(Date.now() + 8 * 60 * 60_000).toISOString(), + refreshToken: "concurrently-rotated-refresh-token", + refreshExpiresAt: new Date(Date.now() + 180 * 24 * 60 * 60_000).toISOString(), + }); + return Response.json({ error: "bad_refresh_token" }); + }); + + await expect(getLiveSessionGitHubToken(env, session.id)).resolves.toBe("concurrently-refreshed-token"); + }); + + it("returns null when refresh fails and no concurrent update landed either", async () => { + const env = createTestEnv({ GITHUB_OAUTH_CLIENT_ID: "client-id", GITHUB_OAUTH_CLIENT_SECRET: "client-secret", TOKEN_ENCRYPTION_SECRET: SECRET }); + const { session } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 42 }, { + githubToken: "near-expiry-token", + githubTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(), + githubRefreshToken: "refresh-token", + githubRefreshTokenExpiresAt: new Date(Date.now() + 180 * 24 * 60 * 60_000).toISOString(), + }); + vi.stubGlobal("fetch", async () => Response.json({ error: "bad_refresh_token" })); + await expect(getLiveSessionGitHubToken(env, session.id)).resolves.toBeNull(); + }); + + it("returns null on the retry when the follow-up read finds nothing at all (e.g. the session was revoked mid-refresh)", async () => { + const env = createTestEnv({ GITHUB_OAUTH_CLIENT_ID: "client-id", GITHUB_OAUTH_CLIENT_SECRET: "client-secret", TOKEN_ENCRYPTION_SECRET: SECRET }); + const { token, session } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 42 }, { + githubToken: "near-expiry-token", + githubTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(), + githubRefreshToken: "refresh-token", + githubRefreshTokenExpiresAt: new Date(Date.now() + 180 * 24 * 60 * 60_000).toISOString(), + }); + // The failed refresh attempt's own network call is the trigger point: simulate the session being revoked + // (e.g. a concurrent logout) in the window between the failed refresh and this function's own retry read, + // by deleting the stored token as a side effect of the mocked fetch itself. + vi.stubGlobal("fetch", async () => { + await deleteSessionGitHubToken(env, session.id); + return Response.json({ error: "bad_refresh_token" }); + }); + await expect(getLiveSessionGitHubToken(env, session.id)).resolves.toBeNull(); + expect(token).toBeTruthy(); // sanity: the fixture session really was created + }); + + it("still attempts a refresh when refreshToken is present but refreshExpiresAt was never recorded", async () => { + const env = createTestEnv({ GITHUB_OAUTH_CLIENT_ID: "client-id", GITHUB_OAUTH_CLIENT_SECRET: "client-secret", TOKEN_ENCRYPTION_SECRET: SECRET }); + const { session } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 42 }, { + githubToken: "near-expiry-token", + githubTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(), + githubRefreshToken: "refresh-token-no-known-expiry", + // githubRefreshTokenExpiresAt deliberately omitted. + }); + vi.stubGlobal("fetch", async () => Response.json({ access_token: "refreshed-token" })); + await expect(getLiveSessionGitHubToken(env, session.id)).resolves.toBe("refreshed-token"); + }); + + it("REGRESSION: refreshGitHubUserToken (via getLiveSessionGitHubToken) treats a malformed/non-JSON refresh response as a failure, not a crash", async () => { + const env = createTestEnv({ GITHUB_OAUTH_CLIENT_ID: "client-id", GITHUB_OAUTH_CLIENT_SECRET: "client-secret", TOKEN_ENCRYPTION_SECRET: SECRET }); + const { session } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 42 }, { + githubToken: "near-expiry-token", + githubTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(), + githubRefreshToken: "refresh-token", + githubRefreshTokenExpiresAt: new Date(Date.now() + 180 * 24 * 60 * 60_000).toISOString(), + }); + vi.stubGlobal("fetch", async () => new Response("{", { status: 200 })); + await expect(getLiveSessionGitHubToken(env, session.id)).resolves.toBeNull(); + }); + + it("REGRESSION: refuses to refresh (and reports unavailable) when GITHUB_OAUTH_CLIENT_ID/SECRET are not configured", async () => { + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + delete (env as Partial).GITHUB_OAUTH_CLIENT_ID; + delete (env as Partial).GITHUB_OAUTH_CLIENT_SECRET; + const { session } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 42 }, { + githubToken: "near-expiry-token", + githubTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(), + githubRefreshToken: "refresh-token", + githubRefreshTokenExpiresAt: new Date(Date.now() + 180 * 24 * 60 * 60_000).toISOString(), + }); + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + await expect(getLiveSessionGitHubToken(env, session.id)).resolves.toBeNull(); + }); + + it("getDecryptedSessionGitHubTokenBundle degrades the refresh half only, keeping the access token usable, when the refresh ciphertext is corrupt", async () => { + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + const { session } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 42 }, { + githubToken: "access-token-ok", + githubRefreshToken: "refresh-token-ok", + }); + await env.DB.prepare("update auth_session_github_tokens set refresh_ciphertext = ? where session_id = ?").bind("corrupted-not-real-ciphertext", session.id).run(); + const bundle = await getDecryptedSessionGitHubTokenBundle(env, session.id); + expect(bundle?.accessToken).toBe("access-token-ok"); + expect(bundle?.refreshToken).toBeNull(); + }); + + it("getDecryptedSessionGitHubTokenBundle returns null when no encryption key is configured", async () => { + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + const { session } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 42 }, { githubToken: "some-token" }); + const noSecretEnv = { ...env, TOKEN_ENCRYPTION_SECRET: undefined } as unknown as Env; + await expect(getDecryptedSessionGitHubTokenBundle(noSecretEnv, session.id)).resolves.toBeNull(); + }); + + it("getDecryptedSessionGitHubTokenBundle returns null (whole bundle, not just the refresh half) when the ACCESS token ciphertext fails to decrypt", async () => { + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + const { session } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 42 }, { githubToken: "some-token", githubRefreshToken: "some-refresh-token" }); + const wrongSecretEnv = { ...env, TOKEN_ENCRYPTION_SECRET: "totally-different-example-secret-32-bytes-min" } as unknown as Env; + await expect(getDecryptedSessionGitHubTokenBundle(wrongSecretEnv, session.id)).resolves.toBeNull(); + }); +}); + describe("POST /v1/auth/github/token route (#6114)", () => { it("returns the session's live GitHub token, never cached", async () => { const app = createApp();