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
8 changes: 7 additions & 1 deletion src/auth/github-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,16 @@ export async function pollGitHubDeviceFlow(env: Env, deviceCode: string) {
);
}

/**
* Starts the GitHub web OAuth flow. `scope` defaults to `"read:user"` (the standard login flow) — pass
* `"read:user repo"` only for the explicit APR idea-submission variant (#7637) that needs to create a repo
* under the customer's own account later; every other caller keeps requesting `read:user` unchanged.
*/
export async function startGitHubWebOAuth(
env: Env,
requestUrl: string,
returnTo: string | undefined,
scope: string = "read:user",
): Promise<{ state: string; authorizationUrl: string; returnTo: string }> {
if (!env.GITHUB_OAUTH_CLIENT_ID || !env.GITHUB_OAUTH_CLIENT_SECRET) throw new Error("github_oauth_not_configured");
const safeReturnTo = normalizeReturnTo(env, returnTo);
Expand All @@ -118,7 +124,7 @@ export async function startGitHubWebOAuth(
const authorizationUrl = new URL("https://github.com/login/oauth/authorize");
authorizationUrl.searchParams.set("client_id", env.GITHUB_OAUTH_CLIENT_ID);
authorizationUrl.searchParams.set("redirect_uri", githubOAuthCallbackUrl(env, requestUrl));
authorizationUrl.searchParams.set("scope", "read:user");
authorizationUrl.searchParams.set("scope", scope);
authorizationUrl.searchParams.set("state", state);
return { state, authorizationUrl: authorizationUrl.toString(), returnTo: safeReturnTo };
}
Expand Down
52 changes: 52 additions & 0 deletions src/orb/apr-repo-creation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// APR (auto-provisioned repo) creation under the submitting customer's own GitHub account (#7637, decision
// #7590 — corrected 2026-07-21). Earlier drafts of this issue specced creating the repo with a fixed/operator
// account's own token, which would put every APR repo under one owner regardless of who actually submitted the
// idea. That is NOT the intended behavior: the repo must be created under the CUSTOMER's own account, using
// THEIR OAuth authorization, via this codebase's existing multi-user session infrastructure
// (src/auth/github-oauth.ts) — never a fixed/operator session, never an installation-token driver.
//
// Requesting the `repo` scope only happens for the customer's own explicit idea-submission OAuth flow (the
// `scope` parameter `startGitHubWebOAuth` now accepts) — the default login flow is completely unaffected.

import { getLiveSessionGitHubToken } from "../auth/github-oauth";
import { githubHeaders, timeoutFetch } from "../github/client";

export type CreateAprRepoResult =
| { created: true; fullName: string; htmlUrl: string; nodeId: string }
| { created: false; status: number | null; error: string };

/**
* Create a new GitHub repository owned by the customer identified by `sessionId`, using THAT session's own
* live OAuth token (never a fixed/operator session) — GitHub's `POST /user/repos` always creates the repo
* under the authenticated user's own account, so the returned `full_name` is `<their-login>/<repoName>`.
*
* Returns a structured `{ created: false }` result rather than throwing on a missing/expired session token or
* a GitHub API error (e.g. a repo-name collision), so callers get a total function they can branch on.
*/
export async function createAprRepoForCustomerSession(
env: Env,
sessionId: string,
repoName: string,
options: { private?: boolean; description?: string } = {},
): Promise<CreateAprRepoResult> {
const token = await getLiveSessionGitHubToken(env, sessionId);
if (!token) return { created: false, status: null, error: "customer_session_token_unavailable" };

const body: Record<string, unknown> = { name: repoName, private: options.private ?? true };
if (options.description) body.description = options.description;

const response = await timeoutFetch("https://github.com/ghapi/user/repos", {
method: "POST",
headers: githubHeaders({ token, json: true }),
body: JSON.stringify(body),
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
return { created: false, status: response.status, error: detail.slice(0, 200) || `repo creation failed (${response.status})` };
}
const payload = (await response.json().catch(() => null)) as { full_name?: string; html_url?: string; node_id?: string } | null;
if (!payload?.full_name || !payload.html_url || !payload.node_id) {
return { created: false, status: response.status, error: "repo creation response missing required fields" };
}
return { created: true, fullName: payload.full_name, htmlUrl: payload.html_url, nodeId: payload.node_id };
}
17 changes: 17 additions & 0 deletions test/unit/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,9 @@ describe("private-beta auth and rate limiting", () => {
expect(started.authorizationUrl).toContain("https://github.com/login/oauth/authorize");
expect(started.authorizationUrl).toContain("client_id=client-id");
expect(started.authorizationUrl).toContain("redirect_uri=https%3A%2F%2Fapi.loopover.ai%2Fv1%2Fauth%2Fgithub%2Fcallback");
// Default login flow requests only read:user -- unaffected by the #7637 scope parameter.
expect(started.authorizationUrl).toContain("scope=read%3Auser");
expect(started.authorizationUrl).not.toContain("repo");

await expect(
startGitHubWebOAuth(createTestEnv({ GITHUB_OAUTH_CLIENT_ID: "client-id" }), "https://loopover-api.aethereal.dev/v1/auth/github/start", undefined),
Expand Down Expand Up @@ -895,6 +898,20 @@ describe("private-beta auth and rate limiting", () => {
).rejects.toThrow(/bad code/);
});

it("requests the repo scope only for the explicit APR idea-submission variant (#7637)", async () => {
const env = createTestEnv({
GITHUB_OAUTH_CLIENT_ID: "client-id",
GITHUB_OAUTH_CLIENT_SECRET: "client-secret",
});
const started = await startGitHubWebOAuth(
env,
"https://loopover-api.aethereal.dev/v1/auth/github/start",
"https://loopover.ai/app/workbench",
"read:user repo",
);
expect(started.authorizationUrl).toContain("scope=read%3Auser+repo");
});

it("normalizes GitHub web OAuth fallbacks and rejects malformed callback state", async () => {
const env = createTestEnv({
GITHUB_OAUTH_CLIENT_ID: "client-id",
Expand Down
102 changes: 102 additions & 0 deletions test/unit/orb-apr-repo-creation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { getLiveSessionGitHubToken } from "../../src/auth/github-oauth";
import { createAprRepoForCustomerSession } from "../../src/orb/apr-repo-creation";
import { createTestEnv } from "../helpers/d1";

// Mock the session-token lookup so no real session/DB state is needed. The mocked value is an opaque,
// obviously-fake placeholder — never a PEM/private-key-shaped fixture (a prior attempt at a sibling APR
// module was auto-closed by the secret scanner for exactly that).
vi.mock("../../src/auth/github-oauth", async (importOriginal) => ({
...(await importOriginal<typeof import("../../src/auth/github-oauth")>()),
getLiveSessionGitHubToken: vi.fn(),
}));
const mockedToken = vi.mocked(getLiveSessionGitHubToken);

/** Capture the outbound request so we can assert the endpoint, method, auth, and body. */
function stubFetch(handler: (url: string, init: RequestInit) => Response): void {
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => handler(String(input), init ?? {}));
}

describe("createAprRepoForCustomerSession (#7637)", () => {
beforeEach(() => {
mockedToken.mockReset();
mockedToken.mockResolvedValue("gho_customer_session_token");
});
afterEach(() => {
vi.unstubAllGlobals();
});

it("POSTs to /user/repos with the customer session's own token, defaulting to private", async () => {
let seenUrl = "";
let seenInit: RequestInit = {};
stubFetch((url, init) => {
seenUrl = url;
seenInit = init;
return new Response(
JSON.stringify({ full_name: "joesmoe/widgets", html_url: "https://github.com/joesmoe/widgets", node_id: "R_abc123" }),
{ status: 201 },
);
});

const env = createTestEnv();
const result = await createAprRepoForCustomerSession(env, "session-1", "widgets");

expect(mockedToken).toHaveBeenCalledWith(env, "session-1");
expect(seenUrl).toBe("https://github.com/ghapi/user/repos");
expect(seenInit.method).toBe("POST");
expect((seenInit.headers as Record<string, string>).authorization).toBe("Bearer gho_customer_session_token");
expect(JSON.parse(String(seenInit.body))).toEqual({ name: "widgets", private: true });
expect(result).toEqual({
created: true,
fullName: "joesmoe/widgets",
htmlUrl: "https://github.com/joesmoe/widgets",
nodeId: "R_abc123",
});
});

it("passes through an explicit private:false and an optional description", async () => {
let seenInit: RequestInit = {};
stubFetch((_url, init) => {
seenInit = init;
return new Response(
JSON.stringify({ full_name: "joesmoe/widgets", html_url: "https://github.com/joesmoe/widgets", node_id: "R_abc123" }),
{ status: 201 },
);
});

await createAprRepoForCustomerSession(createTestEnv(), "session-1", "widgets", { private: false, description: "A widget repo" });

expect(JSON.parse(String(seenInit.body))).toEqual({ name: "widgets", private: false, description: "A widget repo" });
});

it("fails closed without calling GitHub when the customer session has no live token", async () => {
mockedToken.mockResolvedValue(null);
const calls: string[] = [];
stubFetch((url) => {
calls.push(url);
return new Response("", { status: 200 });
});

const result = await createAprRepoForCustomerSession(createTestEnv(), "session-1", "widgets");

expect(result).toEqual({ created: false, status: null, error: "customer_session_token_unavailable" });
expect(calls).toEqual([]);
});

it("returns a structured failure on a GitHub API error (e.g. a repo-name collision) without throwing", async () => {
stubFetch(() => new Response("Repository creation failed.", { status: 422 }));

const result = await createAprRepoForCustomerSession(createTestEnv(), "session-1", "widgets");

expect(result).toEqual({ created: false, status: 422, error: "Repository creation failed." });
});

it("fails closed when GitHub returns 2xx but the payload is missing required fields", async () => {
stubFetch(() => new Response(JSON.stringify({}), { status: 201 }));

const result = await createAprRepoForCustomerSession(createTestEnv(), "session-1", "widgets");

expect(result).toEqual({ created: false, status: 201, error: "repo creation response missing required fields" });
});
});