mirror of
https://github.com/oblien/openship.git
synced 2026-10-02 07:44:35 +08:00
fix(github): integrate existing-installation reconnect into the shared flow
This commit is contained in:
@@ -11,6 +11,8 @@ the in-app updater surfaces critical advisories from `release-advisories.json`.
|
||||
identity can connect multiple Openship accounts without transferring sign-in.
|
||||
Existing installations can be selected, personal tokens remain available in
|
||||
Git settings, and each connection reports its own completion or failure.
|
||||
Direct installation links also offer existing accounts before opening GitHub,
|
||||
avoiding reconnects that end on GitHub settings without a setup callback.
|
||||
- Compose deployments preserve unchanged image services across folder uploads
|
||||
and snapshot syncs. Import metadata updates no longer mark services dirty or
|
||||
overwrite the timestamp of a concurrent configuration edit. Changed project
|
||||
|
||||
@@ -10,6 +10,9 @@ const h = vi.hoisted(() => ({
|
||||
pendingApproval: vi.fn(),
|
||||
authorize: vi.fn(),
|
||||
memberFind: vi.fn(),
|
||||
organizationFind: vi.fn(),
|
||||
listInstallations: vi.fn(),
|
||||
currentInstallations: vi.fn(),
|
||||
claim: vi.fn(),
|
||||
audit: vi.fn(),
|
||||
verify: vi.fn(),
|
||||
@@ -30,8 +33,10 @@ vi.mock("@repo/db", () => ({
|
||||
pendingApproval: h.pendingApproval,
|
||||
},
|
||||
member: { find: h.memberFind },
|
||||
organization: { findById: h.organizationFind },
|
||||
gitInstallation: {
|
||||
claimWithState: h.claim,
|
||||
listByOrganization: h.currentInstallations,
|
||||
findByOrgAndOwner: vi.fn(),
|
||||
},
|
||||
auditEvent: { create: h.audit },
|
||||
@@ -43,6 +48,7 @@ vi.mock("@repo/platform/engine/lib/auth", () => ({
|
||||
}));
|
||||
vi.mock("@repo/platform/engine/config/env", () => ({
|
||||
cloudRuntimeTarget: { api: "https://api.openship.io" },
|
||||
env: { GITHUB_APP_ID: "9" },
|
||||
}));
|
||||
vi.mock("@repo/platform/engine/lib/org-actor", () => ({
|
||||
resolveOrgOwner: vi.fn(),
|
||||
@@ -57,11 +63,13 @@ vi.mock("@repo/platform/engine/modules/github/github.auth", () => ({
|
||||
}));
|
||||
vi.mock("@repo/platform/engine/modules/github/github.installation-verification", () => ({
|
||||
verifyGitHubInstallationForUser: h.verify,
|
||||
listGitHubInstallationsForUser: h.listInstallations,
|
||||
}));
|
||||
|
||||
import {
|
||||
attributeGithubInstall,
|
||||
buildOrgScopedInstallUrl,
|
||||
getGithubInstallSelection,
|
||||
} from "@repo/platform/engine/modules/cloud/cloud-github.service";
|
||||
|
||||
const installation = {
|
||||
@@ -103,6 +111,9 @@ describe("cloud GitHub App installation attribution", () => {
|
||||
h.pendingApproval.mockResolvedValue(true);
|
||||
h.authorize.mockResolvedValue(undefined);
|
||||
h.memberFind.mockResolvedValue({ id: "member_1", role: "member" });
|
||||
h.organizationFind.mockResolvedValue({ id: "org_1", name: "Acme workspace" });
|
||||
h.listInstallations.mockResolvedValue([installation]);
|
||||
h.currentInstallations.mockResolvedValue([]);
|
||||
h.verify.mockResolvedValue({ kind: "ok", installation });
|
||||
h.claim.mockResolvedValue({ id: "installation_row" });
|
||||
h.audit.mockResolvedValue({});
|
||||
@@ -118,7 +129,7 @@ describe("cloud GitHub App installation attribution", () => {
|
||||
|
||||
expect(result.state).toMatch(/^ghrepo_[A-Za-z0-9_-]{32}$/);
|
||||
expect(result.url).toBe(
|
||||
`https://github.com/apps/openship-io/installations/new?state=${result.state}`,
|
||||
`https://api.openship.io/api/cloud/github/install-callback?flow=select&state=${result.state}`,
|
||||
);
|
||||
expect(h.stateCreate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
state: result.state,
|
||||
@@ -127,6 +138,54 @@ describe("cloud GitHub App installation attribution", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("offers only active installations of this App without claiming or consuming the state", async () => {
|
||||
h.listInstallations.mockResolvedValue([
|
||||
installation,
|
||||
{ ...installation, id: 43, app_id: 10 },
|
||||
{ ...installation, id: 44, suspended_at: "2026-09-29T00:00:00Z" },
|
||||
]);
|
||||
|
||||
await expect(getGithubInstallSelection("nonce")).resolves.toEqual({
|
||||
kind: "ready",
|
||||
state: "nonce",
|
||||
workspaceName: "Acme workspace",
|
||||
installUrl: "https://github.com/apps/openship-io/installations/new?state=nonce",
|
||||
installations: [{ id: 42, login: "Acme", avatarUrl: "", type: "Organization", connected: false }],
|
||||
});
|
||||
expect(h.listInstallations).toHaveBeenCalledWith("user_1");
|
||||
expect(h.claim).not.toHaveBeenCalled();
|
||||
expect(h.stateConsume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
{ organizationId: null, sourceId: null, flow: "install" },
|
||||
{ organizationId: "org_1", sourceId: null, flow: "manifest" },
|
||||
{ organizationId: "org_1", sourceId: "custom_app", flow: "install" },
|
||||
])(
|
||||
"does not disclose GitHub accounts for an expired or unrelated setup state: %j",
|
||||
async (binding) => {
|
||||
h.stateFind.mockResolvedValue(binding);
|
||||
|
||||
await expect(getGithubInstallSelection("nonce")).resolves.toEqual({ kind: "state-expired" });
|
||||
expect(h.listInstallations).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("revokes selection access after the initiating user leaves the workspace", async () => {
|
||||
h.memberFind.mockResolvedValue(null);
|
||||
|
||||
await expect(getGithubInstallSelection("nonce")).resolves.toMatchObject({ kind: "forbidden" });
|
||||
expect(h.listInstallations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires GitHub authorization before offering existing installations", async () => {
|
||||
h.listInstallations.mockResolvedValue(null);
|
||||
|
||||
await expect(getGithubInstallSelection("nonce")).resolves.toMatchObject({ kind: "forbidden" });
|
||||
expect(h.claim).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid installation ids before reading or burning state", async () => {
|
||||
const result = await attributeGithubInstall({
|
||||
...callbackInput,
|
||||
|
||||
@@ -69,6 +69,8 @@ import {
|
||||
import {
|
||||
startGithubLinkFromBridgeToken,
|
||||
buildOrgScopedInstallUrl,
|
||||
getGithubInstallSelection,
|
||||
type GithubInstallSelectionResult,
|
||||
attributeGithubInstall,
|
||||
listOrgInstallations,
|
||||
mintOrgInstallationToken,
|
||||
@@ -917,9 +919,9 @@ export async function githubOauthSuccess(c: Context) {
|
||||
/**
|
||||
* POST /api/cloud/github/install-url
|
||||
*
|
||||
* Returns the central App's installation URL with a one-time state token
|
||||
* embedded as a query parameter. GitHub Apps preserve the `state` query
|
||||
* param through the install flow and append it to the Setup URL alongside
|
||||
* Returns Openship's account-selection URL with a one-time state token. It
|
||||
* offers existing installations, or forwards a new install to GitHub with the
|
||||
* same state. GitHub preserves that state and appends it to the Setup URL alongside
|
||||
* `installation_id` and `setup_action` — that's what lets us attribute
|
||||
* the install back to the userId that started the flow without requiring
|
||||
* a SaaS session cookie on the popup browser (the App's Setup URL on
|
||||
@@ -944,6 +946,10 @@ export async function githubInstallUrl(c: Context) {
|
||||
* committed atomically. No browser session is trusted on this callback.
|
||||
*/
|
||||
export async function githubInstallCallback(c: Context) {
|
||||
c.header("Cache-Control", "no-store");
|
||||
c.header("Referrer-Policy", "no-referrer");
|
||||
if (c.req.query("flow") === "select") return githubInstallSelection(c);
|
||||
|
||||
const result = await attributeGithubInstall({
|
||||
installationIdRaw: c.req.query("installation_id"),
|
||||
setupAction: c.req.query("setup_action"),
|
||||
@@ -1011,14 +1017,70 @@ export async function githubInstallCallback(c: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
async function githubInstallSelection(c: Context) {
|
||||
const result = await getGithubInstallSelection(c.req.query("state"));
|
||||
switch (result.kind) {
|
||||
case "ready":
|
||||
if (result.installations.length === 0) return c.redirect(result.installUrl);
|
||||
return c.html(
|
||||
renderCallbackHtml(
|
||||
"Connect GitHub",
|
||||
`Choose the GitHub account to connect to ${result.workspaceName}.`,
|
||||
{ selection: result },
|
||||
),
|
||||
);
|
||||
case "missing-params":
|
||||
case "state-expired":
|
||||
return c.html(
|
||||
renderCallbackHtml(
|
||||
"Install link expired",
|
||||
"This installation link expired or was already used. Start a new connection from Openship.",
|
||||
),
|
||||
400,
|
||||
);
|
||||
case "forbidden":
|
||||
return c.html(renderCallbackHtml("Installation not authorized", result.message), 403);
|
||||
case "failed":
|
||||
console.error("[github install-selection] failed:", result.error);
|
||||
return c.html(
|
||||
renderCallbackHtml(
|
||||
"Could not load GitHub accounts",
|
||||
"Your GitHub accounts could not be loaded. Refresh this page to try again.",
|
||||
),
|
||||
502,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCallbackHtml(
|
||||
title: string,
|
||||
message: string,
|
||||
opts?: { closeAfterMs?: number },
|
||||
opts?: {
|
||||
closeAfterMs?: number;
|
||||
selection?: Extract<GithubInstallSelectionResult, { kind: "ready" }>;
|
||||
},
|
||||
): string {
|
||||
const closeScript = opts?.closeAfterMs
|
||||
? `<script>setTimeout(() => window.close(), ${opts.closeAfterMs});</script>`
|
||||
: "";
|
||||
const selection = opts?.selection;
|
||||
const accounts = selection
|
||||
? `<div class="installations">${selection.installations
|
||||
.map(
|
||||
(installation) => `
|
||||
<form method="get" action="/api/cloud/github/install-callback">
|
||||
<input type="hidden" name="state" value="${escapeHtml(selection.state)}" />
|
||||
<input type="hidden" name="installation_id" value="${escapeHtml(String(installation.id))}" />
|
||||
<input type="hidden" name="setup_action" value="update" />
|
||||
<button type="submit">
|
||||
<strong>${escapeHtml(installation.login)}</strong>
|
||||
<span>${installation.type === "Organization" ? "Organization" : "Personal account"}</span>
|
||||
</button>
|
||||
</form>`,
|
||||
)
|
||||
.join("")}</div>
|
||||
<a class="install-another" href="${escapeHtml(selection.installUrl)}">Install on another GitHub account</a>`
|
||||
: "";
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -1030,12 +1092,20 @@ function renderCallbackHtml(
|
||||
.card { background: #fff; border-radius: 12px; padding: 32px; box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 8px 24px rgba(0,0,0,0.06); }
|
||||
h1 { font-size: 18px; font-weight: 600; margin: 0 0 12px; }
|
||||
p { font-size: 14px; line-height: 1.55; color: #555; margin: 0; }
|
||||
.installations { display: grid; gap: 12px; margin-top: 24px; }
|
||||
.installations button { display: flex; align-items: center; justify-content: space-between; gap: 16px; width: 100%; padding: 14px 16px; border: 1px solid #ddd; border-radius: 8px; background: #fff; color: inherit; font: inherit; text-align: left; cursor: pointer; }
|
||||
.installations button:hover { background: #f6f6f6; }
|
||||
.installations button:focus-visible, .install-another:focus-visible { outline: 2px solid #171717; outline-offset: 3px; }
|
||||
.installations strong { overflow-wrap: anywhere; }
|
||||
.installations span { font-size: 12px; color: #666; }
|
||||
.install-another { display: inline-block; margin-top: 20px; font-size: 14px; color: #333; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>${escapeHtml(title)}</h1>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
${accounts}
|
||||
</div>
|
||||
${closeScript}
|
||||
</body>
|
||||
|
||||
@@ -12,7 +12,10 @@ vi.mock("@repo/platform/engine/modules/github/github.auth", () => ({
|
||||
}));
|
||||
vi.mock("@repo/platform/engine/modules/github/github.http", () => ({ ghFetch: h.ghFetch }));
|
||||
|
||||
import { verifyGitHubInstallationForUser } from "@repo/platform/engine/modules/github/github.installation-verification";
|
||||
import {
|
||||
listGitHubInstallationsForUser,
|
||||
verifyGitHubInstallationForUser,
|
||||
} from "@repo/platform/engine/modules/github/github.installation-verification";
|
||||
|
||||
const installation = {
|
||||
id: 42,
|
||||
@@ -38,10 +41,26 @@ describe("verifyGitHubInstallationForUser", () => {
|
||||
kind: "forbidden",
|
||||
reason: "missing-user-token",
|
||||
});
|
||||
await expect(listGitHubInstallationsForUser("user_1")).resolves.toBeNull();
|
||||
expect(h.ghFetch).not.toHaveBeenCalled();
|
||||
expect(h.appFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers accounts from every page of the user's installation catalog", async () => {
|
||||
const firstPage = Array.from({ length: 100 }, (_, index) => ({
|
||||
...installation,
|
||||
id: index + 1_000,
|
||||
}));
|
||||
h.ghFetch
|
||||
.mockResolvedValueOnce({ total_count: 101, installations: firstPage })
|
||||
.mockResolvedValueOnce({ total_count: 101, installations: [installation] });
|
||||
|
||||
await expect(listGitHubInstallationsForUser("user_1")).resolves.toEqual([
|
||||
...firstPage,
|
||||
installation,
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects an installation id the initiating user cannot access", async () => {
|
||||
h.ghFetch.mockResolvedValue({ total_count: 0, installations: [] });
|
||||
|
||||
|
||||
@@ -84,6 +84,30 @@ async function start(actor: SeededOwner) {
|
||||
return { ...c, state: result.state };
|
||||
}
|
||||
|
||||
async function startInstallLink(actor: SeededOwner) {
|
||||
const c = await clients(actor);
|
||||
// Status explicitly requested with its public install link is also used by
|
||||
// external browsers and the self-hosted Cloud bridge.
|
||||
const result = await c.http.github.getStatus();
|
||||
const state = new URL(result.installUrl).searchParams.get("state");
|
||||
if (!state) throw new Error("Expected a workspace-bound installation link");
|
||||
return { ...c, state, url: result.installUrl };
|
||||
}
|
||||
|
||||
function selectInstallation(html: string, installationId: number) {
|
||||
const form = html
|
||||
.match(/<form\b[\s\S]*?<\/form>/g)
|
||||
?.find((entry) => entry.includes(`name="installation_id" value="${installationId}"`));
|
||||
if (!form) throw new Error(`No connection form for installation ${installationId}`);
|
||||
const action = form.match(/action="([^"]+)"/)?.[1];
|
||||
if (!action) throw new Error("Missing installation callback destination");
|
||||
const fields = [...form.matchAll(/name="([^"]+)" value="([^"]*)"/g)].map((match) => [
|
||||
match[1]!,
|
||||
match[2]!,
|
||||
]);
|
||||
return app.request(`${action}?${new URLSearchParams(fields)}`);
|
||||
}
|
||||
|
||||
function callback(state: string, setupAction = "install") {
|
||||
return app.request(
|
||||
`https://api.openship.test/api/cloud/github/install-callback?${new URLSearchParams({
|
||||
@@ -109,6 +133,119 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("Cloud GitHub installation through HTTP, shared engine, and database", () => {
|
||||
it("reconnects an existing GitHub installation without another GitHub install callback", async () => {
|
||||
const actor = await seedOwner();
|
||||
const c = await startInstallLink(actor);
|
||||
|
||||
// GitHub already has the App, while Openship has no workspace binding.
|
||||
// The old URL goes to GitHub's settings page and never returns a callback.
|
||||
const page = await app.request(c.url);
|
||||
expect(page.status).toBe(200);
|
||||
expect(page.headers.get("cache-control")).toBe("no-store");
|
||||
expect(page.headers.get("referrer-policy")).toBe("no-referrer");
|
||||
const html = await page.text();
|
||||
expect(html).toContain("Acme");
|
||||
expect(html).toContain("Test Org");
|
||||
expect(await repos.gitInstallation.listByOrganization(actor.orgId)).toEqual([]);
|
||||
expect(await repos.githubInstallState.find(c.state)).toBeTruthy();
|
||||
|
||||
const response = await selectInstallation(html, 42);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toContain("GitHub App installed");
|
||||
expect(await c.http.github.getStatus({ includeInstallUrl: false })).toMatchObject({
|
||||
state: { sources: { openshipApp: { connected: true, hasInstallations: true } } },
|
||||
accounts: [{ login: "acme", source: "app" }],
|
||||
});
|
||||
expect(await repos.githubInstallState.find(c.state)).toBeFalsy();
|
||||
expect(await c.http.github.pollConnect({ state: c.state })).toEqual({ status: "complete" });
|
||||
});
|
||||
|
||||
it("links only the selected account when GitHub has several existing installations", async () => {
|
||||
const actor = await seedOwner();
|
||||
const selected = {
|
||||
...installation,
|
||||
id: 43,
|
||||
account: { ...installation.account, login: "SecondAccount", id: 701 },
|
||||
};
|
||||
github.fetch.mockResolvedValue({
|
||||
total_count: 3,
|
||||
installations: [
|
||||
installation,
|
||||
selected,
|
||||
{
|
||||
...installation,
|
||||
id: 44,
|
||||
app_id: 10,
|
||||
account: { ...installation.account, login: "UnrelatedApp" },
|
||||
},
|
||||
],
|
||||
});
|
||||
await db
|
||||
.update(schema.organization)
|
||||
.set({ name: "Team <script>alert(1)</script>" })
|
||||
.where(eq(schema.organization.id, actor.orgId));
|
||||
const c = await startInstallLink(actor);
|
||||
const page = await app.request(c.url);
|
||||
expect(page.status).toBe(200);
|
||||
const html = await page.text();
|
||||
expect(html).toContain("Acme");
|
||||
expect(html).toContain("SecondAccount");
|
||||
expect(html).not.toContain("UnrelatedApp");
|
||||
expect(html).toContain("Team <script>alert(1)</script>");
|
||||
expect(html).not.toContain("<script>alert(1)</script>");
|
||||
|
||||
github.appFetch.mockResolvedValue(selected);
|
||||
expect((await selectInstallation(html, 43)).status).toBe(200);
|
||||
expect(await repos.gitInstallation.listByOrganization(actor.orgId)).toMatchObject([
|
||||
{ installationId: 43, owner: "secondaccount" },
|
||||
]);
|
||||
const other = await clients(await seedOwner());
|
||||
expect(await other.http.github.getStatus({ includeInstallUrl: false })).toMatchObject({
|
||||
accounts: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards a new installation to GitHub with the original workspace state", async () => {
|
||||
const actor = await seedOwner();
|
||||
const c = await startInstallLink(actor);
|
||||
github.fetch.mockResolvedValueOnce({ total_count: 0, installations: [] });
|
||||
|
||||
const response = await app.request(c.url);
|
||||
expect(response.status).toBe(302);
|
||||
const destination = new URL(response.headers.get("location")!);
|
||||
expect(destination.origin).toBe("https://github.com");
|
||||
expect(destination.pathname).toMatch(/\/apps\/[^/]+\/installations\/new$/);
|
||||
expect(destination.searchParams.get("state")).toBe(c.state);
|
||||
expect(await repos.githubInstallState.find(c.state)).toBeTruthy();
|
||||
expect((await callback(c.state)).status).toBe(200);
|
||||
});
|
||||
|
||||
it("rechecks GitHub access when an offered installation is selected", async () => {
|
||||
const actor = await seedOwner();
|
||||
const c = await startInstallLink(actor);
|
||||
const page = await app.request(c.url);
|
||||
expect(page.status).toBe(200);
|
||||
const html = await page.text();
|
||||
github.fetch.mockResolvedValue({ total_count: 0, installations: [] });
|
||||
|
||||
expect((await selectInstallation(html, 42)).status).toBe(403);
|
||||
expect(await repos.gitInstallation.listByOrganization(actor.orgId)).toEqual([]);
|
||||
expect(await repos.githubInstallState.find(c.state)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps a failed installation lookup retryable instead of sending the user back to GitHub", async () => {
|
||||
const actor = await seedOwner();
|
||||
const c = await startInstallLink(actor);
|
||||
github.fetch.mockRejectedValueOnce(new Error("GitHub temporarily unavailable"));
|
||||
|
||||
const failed = await app.request(c.url);
|
||||
expect(failed.status).toBe(502);
|
||||
expect(failed.headers.get("location")).toBeNull();
|
||||
expect(await failed.text()).toContain("Could not load GitHub accounts");
|
||||
expect(await repos.githubInstallState.find(c.state)).toBeTruthy();
|
||||
expect((await app.request(c.url)).status).toBe(200);
|
||||
});
|
||||
|
||||
it("reads status without minting install nonces, while preserving the normal install URL response", async () => {
|
||||
const actor = await seedOwner();
|
||||
const c = await clients(actor);
|
||||
|
||||
@@ -366,7 +366,12 @@ describe("Cloud repository authorization from browser callback through library a
|
||||
it("also checks the issuing session at installation completion", async () => {
|
||||
const owner = await actor();
|
||||
const attempt = await authorize(owner);
|
||||
const selectionUrl = `${apiOrigin}/api/cloud/github/install-callback?${new URLSearchParams({ state: attempt.state, flow: "select" })}`;
|
||||
const selection = await app.request(selectionUrl);
|
||||
expect(selection.status).toBe(200);
|
||||
expect(await selection.text()).toContain("Acme");
|
||||
await db.delete(schema.session).where(eq(schema.session.id, owner.sessionId));
|
||||
expect((await app.request(selectionUrl)).status).toBe(403);
|
||||
const response = await app.request(
|
||||
`${apiOrigin}/api/cloud/github/install-callback?${new URLSearchParams({ state: attempt.state, installation_id: "42", setup_action: "install" })}`,
|
||||
);
|
||||
@@ -374,6 +379,29 @@ describe("Cloud repository authorization from browser callback through library a
|
||||
expect(await repos.gitInstallation.listByOrganization(owner.orgId)).toEqual([]);
|
||||
});
|
||||
|
||||
it("completes repository authorization through the public picker without a Cloud session cookie", async () => {
|
||||
const owner = await actor();
|
||||
const attempt = await authorize(owner);
|
||||
const page = await app.request(
|
||||
`${apiOrigin}/api/cloud/github/install-callback?${new URLSearchParams({ state: attempt.state, flow: "select" })}`,
|
||||
);
|
||||
expect(page.status).toBe(200);
|
||||
const html = await page.text();
|
||||
expect(html).toContain(`name="state" value="${attempt.state}"`);
|
||||
expect(html).toContain('name="installation_id" value="42"');
|
||||
expect(page.headers.get("set-cookie")).toBeNull();
|
||||
const selected = await app.request(
|
||||
`${apiOrigin}/api/cloud/github/install-callback?${new URLSearchParams({ state: attempt.state, installation_id: "42", setup_action: "update" })}`,
|
||||
);
|
||||
expect(selected.status).toBe(200);
|
||||
expect(await owner.client.github.pollConnect({ state: attempt.state })).toEqual({
|
||||
status: "complete",
|
||||
});
|
||||
expect((await owner.client.github.getHome()).repos).toMatchObject([
|
||||
{ full_name: "Acme/private-app" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("completes OAuth and an installation claim once when callbacks race", async () => {
|
||||
const owner = await actor();
|
||||
const attempt = await start(owner);
|
||||
|
||||
@@ -104,6 +104,13 @@ The public `GET` handoff routes take query params, not a body — `desktop-hando
|
||||
expect `redirect`, `state`, and `code_challenge`; `github/install-callback` expects `installation_id`,
|
||||
`setup_action`, and `state`; `github/oauth-bridge` expects `token`.
|
||||
|
||||
GitHub installation links first open Openship's account selection using `flow=select` and
|
||||
`state`. The user can reconnect an existing installation or continue to GitHub for a new
|
||||
one. This also works in the external browser used by self-hosted and desktop instances:
|
||||
the temporary state binds the choice to its initiating user and workspace without requiring
|
||||
a Cloud browser session. Discovery and selection recheck access, and the installation claim
|
||||
uses the same verification and transaction as the dashboard picker.
|
||||
|
||||
### Deploy proxies (preflight, edge proxy, analytics)
|
||||
|
||||
```
|
||||
@@ -306,8 +313,8 @@ from the dashboard.
|
||||
| Check edge-proxy domain verification. | `POST /api/cloud/edge-proxy/verify-check`<br />`cloud:write` |
|
||||
| Exchange a one-time code for user + session (rate-limited). | `POST /api/cloud/exchange-code`<br />`Handler authentication` |
|
||||
| Export an org- or project-scope subgraph dump (rate-limited). | `POST /api/cloud/export-subgraph`<br />`cloud:admin` |
|
||||
| GitHub install callback — atomically claim a durable user/workspace nonce after matching user-token and Openship App-JWT verification. | `GET /api/cloud/github/install-callback`<br />`Handler authentication` |
|
||||
| Build the App install URL with a one-time state token. | `POST /api/cloud/github/install-url`<br />`cloud:write` |
|
||||
| Select an existing GitHub installation with flow=select, or atomically claim the workspace-bound attempt after GitHub user-token and App-JWT verification. | `GET /api/cloud/github/install-callback`<br />`Handler authentication` |
|
||||
| Build an Openship installation-selection URL with a one-time workspace state; existing installations can reconnect without another GitHub setup callback. | `POST /api/cloud/github/install-url`<br />`cloud:write` |
|
||||
| Mint a short-lived (~60min) installation access token. | `POST /api/cloud/github/installation-token`<br />`cloud:write` |
|
||||
| List the org owner's GitHub App installations. | `GET /api/cloud/github/installations`<br />`cloud:read` |
|
||||
| Consume the bridge token and redirect the browser into GitHub OAuth. | `GET /api/cloud/github/oauth-bridge`<br />`Handler authentication` |
|
||||
|
||||
@@ -15,14 +15,18 @@
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { cloudRuntimeTarget } from "../../config/env";
|
||||
import { cloudRuntimeTarget, env } from "../../config/env";
|
||||
import { repos } from "@repo/db";
|
||||
import { AppError, safeErrorMessage } from "@repo/core";
|
||||
import type { GitHubInstallationSelection } from "@repo/contracts";
|
||||
import * as githubAuth from "../github/github.auth";
|
||||
import { createEphemeralStore } from "../../lib/ephemeral-store";
|
||||
import { buildBackgroundContext } from "../../lib/background-context";
|
||||
import { resolveOrgOwner } from "../../lib/org-actor";
|
||||
import { verifyGitHubInstallationForUser } from "../github/github.installation-verification";
|
||||
import {
|
||||
listGitHubInstallationsForUser,
|
||||
verifyGitHubInstallationForUser,
|
||||
} from "../github/github.installation-verification";
|
||||
import { REPOSITORY_OAUTH_STATE_PREFIX, beginRepositoryAuthorization, repositoryOAuthStateCookie, repositoryOAuthCookieName, assertRepositoryConnectionActor } from "../github/github-repository-authorization";
|
||||
|
||||
// ─── OAuth bridge store (shared between handoff + bridge handlers) ──────────
|
||||
@@ -107,9 +111,84 @@ export async function buildOrgScopedInstallUrl(
|
||||
organizationId,
|
||||
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
|
||||
});
|
||||
const baseUrl = githubAuth.getInstallUrl();
|
||||
const url = `${baseUrl}?state=${encodeURIComponent(state)}`;
|
||||
return { url, state };
|
||||
// GitHub sends an already-installed App to its settings page without a setup
|
||||
// callback. Start on Openship so the user can explicitly claim that existing
|
||||
// installation instead of getting stuck waiting for another GitHub install.
|
||||
const url = new URL("/api/cloud/github/install-callback", cloudRuntimeTarget.api);
|
||||
url.search = new URLSearchParams({ flow: "select", state }).toString();
|
||||
return { url: url.toString(), state };
|
||||
}
|
||||
|
||||
export type GithubInstallSelectionResult =
|
||||
| {
|
||||
kind: "ready";
|
||||
state: string;
|
||||
workspaceName: string;
|
||||
installUrl: string;
|
||||
installations: GitHubInstallationSelection["installations"];
|
||||
}
|
||||
| { kind: "missing-params" }
|
||||
| { kind: "state-expired" }
|
||||
| { kind: "forbidden"; message: string }
|
||||
| { kind: "failed"; error: string };
|
||||
|
||||
/** Offer existing installations without importing them into a workspace.
|
||||
* Selection uses the same one-shot, user/workspace-bound callback as a new
|
||||
* GitHub install; the claim re-verifies access after the user makes a choice. */
|
||||
export async function getGithubInstallSelection(
|
||||
state: string | undefined,
|
||||
): Promise<GithubInstallSelectionResult> {
|
||||
if (!state) return { kind: "missing-params" };
|
||||
try {
|
||||
const binding = await repos.githubInstallState.find(state);
|
||||
if (!binding?.organizationId || binding.sourceId || binding.flow !== "install") {
|
||||
return { kind: "state-expired" };
|
||||
}
|
||||
await assertRepositoryConnectionActor(
|
||||
binding.userId,
|
||||
binding.organizationId,
|
||||
binding.payload.sessionId,
|
||||
);
|
||||
const workspace = await repos.organization.findById(binding.organizationId);
|
||||
if (!workspace) return { kind: "state-expired" };
|
||||
|
||||
const appId = Number(env.GITHUB_APP_ID);
|
||||
if (!Number.isSafeInteger(appId) || appId <= 0) {
|
||||
throw new Error("The Openship GitHub App is not configured.");
|
||||
}
|
||||
const available = await listGitHubInstallationsForUser(binding.userId);
|
||||
if (!available) {
|
||||
return {
|
||||
kind: "forbidden",
|
||||
message: "GitHub authorization is missing. Start the connection again from Openship.",
|
||||
};
|
||||
}
|
||||
const installUrl = new URL(githubAuth.getInstallUrl());
|
||||
installUrl.searchParams.set("state", state);
|
||||
const connected = await repos.gitInstallation.listByOrganization(binding.organizationId);
|
||||
return {
|
||||
kind: "ready",
|
||||
state,
|
||||
workspaceName: workspace.name,
|
||||
installUrl: installUrl.toString(),
|
||||
installations: available
|
||||
.filter((installation) => installation.app_id === appId && !installation.suspended_at)
|
||||
.map((installation) => ({
|
||||
id: installation.id,
|
||||
login: installation.account.login,
|
||||
avatarUrl: installation.account.avatar_url,
|
||||
type: installation.account.type,
|
||||
connected: connected.some(
|
||||
(entry) => !entry.sourceId && entry.installationId === installation.id,
|
||||
),
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError && error.statusCode < 500) {
|
||||
return { kind: "forbidden", message: error.message };
|
||||
}
|
||||
return { kind: "failed", error: safeErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Install callback: state-based attribution ───────────────────────────────
|
||||
|
||||
@@ -158,37 +158,87 @@ async function installationRedirect(ctx: ExecutionContext) {
|
||||
};
|
||||
}
|
||||
|
||||
async function connectCloudApp(ctx: ExecutionContext, input: NonNullable<Parameters<GitHubOperations["connect"]>[0]>) {
|
||||
if (input.source === "cli") throw new AppError("Use a GitHub App or personal token on Openship Cloud.", 400, "NOT_SUPPORTED");
|
||||
async function connectCloudApp(
|
||||
ctx: ExecutionContext,
|
||||
input: NonNullable<Parameters<GitHubOperations["connect"]>[0]>,
|
||||
) {
|
||||
if (input.source === "cli")
|
||||
throw new AppError(
|
||||
"Use a GitHub App or personal token on Openship Cloud.",
|
||||
400,
|
||||
"NOT_SUPPORTED",
|
||||
);
|
||||
const status = await githubAuth.getUserStatus(ctx.userId, ctx);
|
||||
const current = status.connected ? await githubAuth.getUserInstallations(ctx, status) : [];
|
||||
if (status.connected && current.length && !input.source && !input.state) return { connected: true as const };
|
||||
let install: { state: string; url: string };
|
||||
if (status.connected && !input.source && !input.state) {
|
||||
const current = await githubAuth.getUserInstallations(ctx, status);
|
||||
if (current.length) return { connected: true as const };
|
||||
}
|
||||
let state: string;
|
||||
if (input.state) {
|
||||
const binding = await repos.githubInstallState.find(input.state);
|
||||
if (!binding || binding.flow !== "install" || binding.sourceId || binding.userId !== ctx.userId || binding.organizationId !== ctx.organizationId) {
|
||||
throw new AppError("This GitHub connection attempt expired or belongs to another workspace. Start again.", 409, "GITHUB_ATTEMPT_EXPIRED");
|
||||
if (
|
||||
!binding ||
|
||||
binding.flow !== "install" ||
|
||||
binding.sourceId ||
|
||||
binding.userId !== ctx.userId ||
|
||||
binding.organizationId !== ctx.organizationId
|
||||
) {
|
||||
throw new AppError(
|
||||
"This GitHub connection attempt expired or belongs to another workspace. Start again.",
|
||||
409,
|
||||
"GITHUB_ATTEMPT_EXPIRED",
|
||||
);
|
||||
}
|
||||
install = { state: input.state, url: `${githubAuth.getInstallUrl()}?state=${encodeURIComponent(input.state)}` };
|
||||
state = input.state;
|
||||
} else {
|
||||
const result = await githubAuth.resolveInstallUrl(ctx);
|
||||
if (!result.state || !result.url) throw new AppError("Could not start GitHub installation. Try again.", 503, "GITHUB_APP_UNAVAILABLE");
|
||||
install = { state: result.state, url: result.url };
|
||||
if (!result.state || !result.url)
|
||||
throw new AppError(
|
||||
"Could not start GitHub installation. Try again.",
|
||||
503,
|
||||
"GITHUB_APP_UNAVAILABLE",
|
||||
);
|
||||
state = result.state;
|
||||
}
|
||||
if (!status.connected) return {
|
||||
connected: false as const, flow: "redirect" as const, step: "install" as const, completion: "attempt" as const,
|
||||
url: `${resolveAuthBaseUrl()}/api/github/connect/redirect?install_state=${encodeURIComponent(install.state)}`, state: install.state,
|
||||
if (!status.connected)
|
||||
return {
|
||||
connected: false as const,
|
||||
flow: "redirect" as const,
|
||||
step: "install" as const,
|
||||
completion: "attempt" as const,
|
||||
url: `${resolveAuthBaseUrl()}/api/github/connect/redirect?install_state=${encodeURIComponent(state)}`,
|
||||
state,
|
||||
};
|
||||
// Direct Cloud dashboards and external install links share discovery and
|
||||
// workspace checks. Their browser adapters render the same verified choices.
|
||||
const { getGithubInstallSelection } = await import("../cloud/cloud-github.service");
|
||||
const selection = await getGithubInstallSelection(state);
|
||||
if (selection.kind === "forbidden")
|
||||
throw new AppError(selection.message, 403, "GITHUB_INSTALLATION_FAILED");
|
||||
if (selection.kind === "failed")
|
||||
throw new AppError(selection.error, 502, "GITHUB_INSTALLATION_FAILED");
|
||||
if (selection.kind !== "ready")
|
||||
throw new AppError(
|
||||
"This GitHub connection attempt expired. Start again.",
|
||||
409,
|
||||
"GITHUB_ATTEMPT_EXPIRED",
|
||||
);
|
||||
if (selection.installations.length)
|
||||
return {
|
||||
connected: false,
|
||||
flow: "installations",
|
||||
state,
|
||||
installUrl: selection.installUrl,
|
||||
installations: selection.installations,
|
||||
} satisfies GitHubInstallationSelection;
|
||||
return {
|
||||
connected: false as const,
|
||||
flow: "redirect" as const,
|
||||
step: "install" as const,
|
||||
completion: "attempt" as const,
|
||||
state,
|
||||
url: selection.installUrl,
|
||||
};
|
||||
const { listAvailableGitHubInstallations } = await import("./github.installation-verification");
|
||||
const available = await listAvailableGitHubInstallations(ctx.userId);
|
||||
if (available.length) return {
|
||||
connected: false, flow: "installations", state: install.state, installUrl: install.url,
|
||||
installations: available.map((entry) => ({
|
||||
id: entry.id, login: entry.account.login, avatarUrl: entry.account.avatar_url, type: entry.account.type,
|
||||
connected: current.some((item) => item.id === entry.id),
|
||||
})),
|
||||
} satisfies GitHubInstallationSelection;
|
||||
return { connected: false as const, flow: "redirect" as const, step: "install" as const, completion: "attempt" as const, ...install };
|
||||
}
|
||||
|
||||
/** POST /github/connect - Normalized connection flow.
|
||||
|
||||
@@ -12,7 +12,6 @@ import { appFetch, getUserToken } from "./github.auth";
|
||||
import type { GitSource } from "@repo/db";
|
||||
import { githubAppFetch } from "./github.app-client";
|
||||
import { sourceClientCredentials } from "./github-source.service";
|
||||
import { env } from "../../config/env";
|
||||
import type { GitHubInstallation } from "@repo/contracts";
|
||||
|
||||
export type GitHubInstallationVerificationResult =
|
||||
@@ -23,18 +22,7 @@ export type GitHubInstallationVerificationResult =
|
||||
message: string;
|
||||
};
|
||||
|
||||
async function findUserInstallation(
|
||||
token: string,
|
||||
installationId: number,
|
||||
): Promise<GitHubInstallation | null> {
|
||||
for await (const batch of userInstallationPages(token)) {
|
||||
const match = batch.find((installation) => installation.id === installationId);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function* userInstallationPages(token: string): AsyncGenerator<GitHubInstallation[]> {
|
||||
async function* userInstallations(token: string): AsyncGenerator<GitHubInstallation> {
|
||||
const perPage = 100;
|
||||
for (let page = 1; page <= 50; page++) {
|
||||
const data = await ghFetch<{
|
||||
@@ -48,7 +36,7 @@ async function* userInstallationPages(token: string): AsyncGenerator<GitHubInsta
|
||||
throw new Error("GitHub returned an invalid installation count.");
|
||||
}
|
||||
const batch = data.installations ?? [];
|
||||
yield batch;
|
||||
yield* batch;
|
||||
if (batch.length < perPage || page * perPage >= data.total_count) {
|
||||
return;
|
||||
}
|
||||
@@ -56,17 +44,28 @@ async function* userInstallationPages(token: string): AsyncGenerator<GitHubInsta
|
||||
throw new Error("GitHub returned too many installations. Narrow App access on GitHub and try again.");
|
||||
}
|
||||
|
||||
/** Discover through this user's GitHub authorization, never another tenant's DB. */
|
||||
export async function listAvailableGitHubInstallations(userId: string): Promise<GitHubInstallation[]> {
|
||||
/** Read GitHub's live catalog for an explicit connection attempt. This must
|
||||
* never replace the workspace-scoped catalog used by status and repo reads. */
|
||||
export async function listGitHubInstallationsForUser(
|
||||
userId: string,
|
||||
): Promise<GitHubInstallation[] | null> {
|
||||
const token = await getUserToken(userId);
|
||||
if (!token) return [];
|
||||
if (!token) return null;
|
||||
const installations: GitHubInstallation[] = [];
|
||||
for await (const batch of userInstallationPages(token)) {
|
||||
installations.push(...batch.filter((entry) => entry.app_id === Number(env.GITHUB_APP_ID) && !entry.suspended_at));
|
||||
}
|
||||
for await (const installation of userInstallations(token)) installations.push(installation);
|
||||
return installations;
|
||||
}
|
||||
|
||||
async function findUserInstallation(
|
||||
token: string,
|
||||
installationId: number,
|
||||
): Promise<GitHubInstallation | null> {
|
||||
for await (const installation of userInstallations(token)) {
|
||||
if (installation.id === installationId) return installation;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function verifyGitHubInstallationForUser(
|
||||
userId: string,
|
||||
installationId: number,
|
||||
|
||||
@@ -2864,11 +2864,11 @@
|
||||
},
|
||||
"GET /api/cloud/github/install-callback": {
|
||||
"page": "cloud",
|
||||
"description": "GitHub install callback — atomically claim a durable user/workspace nonce after matching user-token and Openship App-JWT verification."
|
||||
"description": "Select an existing GitHub installation with flow=select, or atomically claim the workspace-bound attempt after GitHub user-token and App-JWT verification."
|
||||
},
|
||||
"POST /api/cloud/github/install-url": {
|
||||
"page": "cloud",
|
||||
"description": "Build the App install URL with a one-time state token."
|
||||
"description": "Build an Openship installation-selection URL with a one-time workspace state; existing installations can reconnect without another GitHub setup callback."
|
||||
},
|
||||
"POST /api/cloud/github/installation-token": {
|
||||
"page": "cloud",
|
||||
|
||||
Reference in New Issue
Block a user