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
42 changes: 42 additions & 0 deletions apps/loopover-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
17 changes: 17 additions & 0 deletions migrations/0153_auth_session_github_tokens.sql
Original file line number Diff line number Diff line change
@@ -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)
);
16 changes: 16 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ import {
deleteRepositoryLinearKey,
getGlobalAgentFrozenState,
setGlobalAgentFrozen,
getDecryptedSessionGitHubToken,
} from "../db/repositories";
import { dedupeSignalSnapshots, pruneExpiredRecords, RETENTION_POLICY } from "../db/retention";
import {
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion src/auth/github-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

Expand Down
7 changes: 6 additions & 1 deletion src/auth/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
getAuthSessionByTokenHash,
recordAuditEvent,
revokeAuthSession,
storeSessionGitHubToken,
touchAuthSession,
} from "../db/repositories";
import type { AuthSessionRecord, JsonValue } from "../types";
Expand Down Expand Up @@ -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<string, JsonValue> } = {},
// `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 } = {},
): Promise<{ token: string; session: AuthSessionRecord }> {
const token = createOpaqueToken();
const issuedAt = nowIso();
Expand All @@ -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,
Expand Down
57 changes: 57 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
agentRecommendationOutcomes,
agentRuns,
auditEvents,
authSessionGithubTokens,
authSessions,
bounties,
bountyLifecycleEvents,
Expand Down Expand Up @@ -1809,6 +1810,62 @@ export async function touchAuthSession(env: Env, sessionId: string): Promise<voi
export async function revokeAuthSession(env: Env, sessionId: string): Promise<void> {
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<void> {
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<string | null> {
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<void> {
const db = getDb(env.DB);
await db.delete(authSessionGithubTokens).where(eq(authSessionGithubTokens.sessionId, sessionId));
}

export async function countActiveAuthSessions(env: Env): Promise<number> {
Expand Down
14 changes: 14 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
{
Expand Down
13 changes: 12 additions & 1 deletion src/openapi/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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/");
}
Loading
Loading