Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions migrations/0155_auth_session_github_token_refresh.sql
Original file line number Diff line number Diff line change
@@ -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;
10 changes: 5 additions & 5 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -113,7 +113,6 @@ import {
deleteRepositoryLinearKey,
getGlobalAgentFrozenState,
setGlobalAgentFrozen,
getDecryptedSessionGitHubToken,
} from "../db/repositories";
import { dedupeSignalSnapshots, pruneExpiredRecords, RETENTION_POLICY } from "../db/retention";
import {
Expand Down Expand Up @@ -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" });
Expand Down
132 changes: 119 additions & 13 deletions src/auth/github-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 = {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -166,7 +175,10 @@ export async function createSessionFromGitHubToken(
env: Env,
githubToken: string,
metadata: Record<string, JsonValue> = {},
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
Expand Down Expand Up @@ -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 };
}

Expand Down Expand Up @@ -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<string | null> {
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`;
Expand Down
19 changes: 17 additions & 2 deletions src/auth/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, JsonValue>; 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<string, JsonValue>;
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();
Expand All @@ -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,
Expand Down
Loading
Loading