diff --git a/docs/github-app-setup.md b/docs/github-app-setup.md index 4512d78..9d25b6f 100644 --- a/docs/github-app-setup.md +++ b/docs/github-app-setup.md @@ -95,8 +95,15 @@ form. Existing installations have direct account-specific configuration links. GitHub may require organization administrator approval. Use **Refresh access after approval** on the Connections page when approval is delayed. -App-connected accounts default to the App for new repository/PR access. The -explicit **Use existing OAuth access** choice handles repositories not yet +App-connected accounts default to the App for new repository/PR access. +Public repositories outside the selected installations use the App user grant, +so users can paste a public URL without installing the App on its owner account. +The connection records the repository ID and checks that it is still public on +each source access. If it becomes private, reconnect through an installation +with access. Existing installation bindings retain their installation checks. +This uses GitHub's documented [public resource access for App user tokens](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app). + +The explicit **Use existing OAuth access** choice handles repositories not yet available through the App. An App error never silently selects OAuth. Gists continue using OAuth. An App-only user entering a gist URL is prompted to connect OAuth, with the current repository permission scope explained. The form draft diff --git a/src/core/github-app.ts b/src/core/github-app.ts index c1ef41d..24b3e1a 100644 --- a/src/core/github-app.ts +++ b/src/core/github-app.ts @@ -220,7 +220,19 @@ async function installationToken(binding: RepositoryAccess, ownerId: string): Pr } export async function boundAppToken(ownerId: string, binding: RepositoryAccess): Promise { - if (!Number.isSafeInteger(binding.repositoryId) || !Number.isSafeInteger(binding.installationId)) throw appError(); + if (!Number.isSafeInteger(binding.repositoryId)) throw appError(); + if (binding.publicRead === true) { + if (binding.installationId !== undefined) throw appError(); + const userToken = await appUserToken(ownerId); + const repo = await githubRequest(`/repositories/${binding.repositoryId}`, userToken); + // A public binding must never gain private access, even if the user later + // installs the App on this repository. Reconnect explicitly to do that. + if (repo.id !== binding.repositoryId || repo.private !== false) throw appError("github_app_access_required"); + // Resolve the current grant again on each source read. Do not register a + // repository-specific renewal callback against a user token shared by repos. + return userToken; + } + if (!Number.isSafeInteger(binding.installationId)) throw appError(); const userToken = await appUserToken(ownerId); // User token checks the intersection of user and App rights on every access. // No indefinite local authorization cache can preserve a departed user's access. @@ -239,7 +251,15 @@ export async function selectRepositoryAccess(ownerId: string, fullName: string, const hasApp = config.GITHUB_APP_ENABLED && await CredentialModel.exists({ ownerId, provider: APP_PROVIDER }); if (choice === "github-app" || (choice === undefined && hasApp)) { const repo = (await appRepositories(ownerId)).find(r => r.full_name.toLowerCase() === fullName.toLowerCase()); - if (!repo) throw appError("github_app_access_required"); + if (!repo) { + const userToken = await appUserToken(ownerId); + const publicRepo = await githubRequest( + `/repos/${fullName.split("/").map(encodeURIComponent).join("/")}`, userToken); + if (publicRepo.private !== false || !Number.isSafeInteger(publicRepo.id)) throw appError("github_app_access_required"); + const binding: RepositoryAccess = { kind: "github-app", publicRead: true, + repositoryId: publicRepo.id, revision: randomUUID() }; + return { binding, token: await boundAppToken(ownerId, binding) }; + } const binding: RepositoryAccess = { kind: "github-app", repositoryId: repo.id, installationId: repo.installationId, revision: randomUUID() }; return { binding, token: await boundAppToken(ownerId, binding) }; } diff --git a/src/core/model/repository-access.schema.ts b/src/core/model/repository-access.schema.ts index 6e18f53..8c7f805 100644 --- a/src/core/model/repository-access.schema.ts +++ b/src/core/model/repository-access.schema.ts @@ -3,6 +3,7 @@ import { Schema } from "mongoose"; export const repositoryAccessSchema = new Schema({ kind: { type: String, enum: ["oauth", "github-app"], required: true }, repositoryId: Number, + publicRead: Boolean, installationId: Number, revision: { type: String, required: true }, }, { _id: false }); diff --git a/src/core/repository-access.types.ts b/src/core/repository-access.types.ts index 7bbeb1a..46ac0a5 100644 --- a/src/core/repository-access.types.ts +++ b/src/core/repository-access.types.ts @@ -2,6 +2,8 @@ export interface RepositoryAccess { kind: "oauth" | "github-app"; repositoryId?: number; + /** App user access to a verified public repository, without an installation. */ + publicRead?: boolean; installationId?: number; revision: string; } diff --git a/test/github-app.test.js b/test/github-app.test.js index 6fb7fdb..447a523 100644 --- a/test/github-app.test.js +++ b/test/github-app.test.js @@ -197,6 +197,72 @@ describeMongo("GitHub App credential and repository integration", function () { expect(selected.token).to.equal("legacy-secret"); expect(selected.binding.kind).to.equal("oauth"); }); + it("opens public repository metadata and branches without an installation or OAuth", async () => { + await app.saveAppGrant(owner.id, data()); + mock(url => { + if (url.includes("/user/installations")) return { installations: [] }; + if (url.endsWith("/branches?per_page=100")) return [{ name: "main", commit: { sha: "abc123" } }]; + if (url.endsWith("/branches")) return [{ name: "main", commit: { sha: "abc123" } }]; + if (url.endsWith("/readme")) return { status: 404 }; + if (url.endsWith("/pages")) return { status: 404 }; + return { id: 7, private: false, full_name: "other/public", name: "public", owner: { login: "other" }, default_branch: "main" }; + }); + const selected = await app.selectRepositoryAccess(owner.id, "other/public", "github-app"); + expect(selected.token).to.equal("ghu_access1"); + expect(selected.binding).to.include({ kind: "github-app", publicRead: true, repositoryId: 7 }); + expect(selected.binding.installationId).to.equal(undefined); + const Repos = require("../src/core/model/anonymizedRepositories/anonymizedRepositories.model").default; + const model = await Repos.create({ repoId: "public-access", owner: owner.id, githubAccess: selected.binding }); + expect((await Repos.findById(model._id)).githubAccess.publicRead).to.equal(true); + const { getRepositoryFromGitHub } = require("../src/core/source/GitHubRepository"); + const repository = await getRepositoryFromGitHub({ owner: "other", repo: "public", accessToken: selected.token }); + expect(repository.fullName).to.equal("other/public"); + expect((await repository.branches({ accessToken: selected.token }))[0].name).to.equal("main"); + expect(calls.some(call => call.url.includes("/access_tokens"))).to.equal(false); + expect(calls.every(call => !String(call.options.headers.authorization || call.options.headers.Authorization).includes("legacy"))).to.equal(true); + }); + it("requires explicit public visibility when selecting an uninstalled repository", async () => { + await app.saveAppGrant(owner.id, data()); + for (const metadata of [{ id: 7, private: true }, { id: 7 }, { private: false }]) { + mock(url => url.includes("/user/installations") ? { installations: [] } : metadata); + await rejects(app.selectRepositoryAccess(owner.id, "other/private", "github-app"), "github_app_access_required"); + } + }); + it("rejects missing repositories and propagates upstream failures instead of using OAuth", async () => { + await setCredential(owner.id, "legacy-secret"); + await app.saveAppGrant(owner.id, data()); + for (const [status, message] of [[404, "github_app_access_required"], [500, "github_unavailable"], [429, "github_rate_limit_exceeded"]]) { + mock(url => url.includes("/user/installations") ? { installations: [] } : { status }); + await rejects(app.selectRepositoryAccess(owner.id, "other/missing", "github-app"), message); + } + }); + it("stops public source reads after visibility changes, deletion, or grant revocation", async () => { + await app.saveAppGrant(owner.id, data()); + const binding = { kind: "github-app", publicRead: true, repositoryId: 7, revision: "public" }; + mock(() => ({ id: 7, private: false })); + expect(await app.boundAppToken(owner.id, binding)).to.equal("ghu_access1"); + for (const metadata of [{ id: 7, private: true }, { status: 404 }, { id: 8, private: false }]) { + mock(() => metadata); + await rejects(app.boundAppToken(owner.id, binding), "github_app_access_required"); + } + await Credentials.updateOne({ ownerId: owner.id }, { $set: { revoked: true } }); + mock(() => { throw new Error("revoked grant must not reach GitHub"); }); + await rejects(app.boundAppToken(owner.id, binding), "github_app_reconnect_required"); + }); + it("refreshes an expired App grant before reading a public repository", async () => { + await app.saveAppGrant(owner.id, data("old", -1)); + mock(url => url.includes("/login/oauth/access_token") ? data("new") : { id: 7, private: false }); + expect(await app.boundAppToken(owner.id, { kind: "github-app", publicRead: true, repositoryId: 7, revision: "public" })) + .to.equal("ghu_accessnew"); + }); + it("does not reinterpret missing or mixed installation bindings as public access", async () => { + await app.saveAppGrant(owner.id, data()); + mock(() => { throw new Error("invalid binding must not reach GitHub"); }); + for (const binding of [ + { kind: "github-app", repositoryId: 7, revision: "one" }, + { kind: "github-app", publicRead: true, repositoryId: 7, installationId: 4, revision: "one" }, + ]) await rejects(app.boundAppToken(owner.id, binding), "github_app_reconnect_required"); + }); it("checks the user's access before minting a repository-restricted token", async () => { await app.saveAppGrant(owner.id, data()); const binding = { kind: "github-app", installationId: 4, repositoryId: 7, revision: "one" };