fix: allow GitHub App access to public repositories without installation

This commit is contained in:
Thomas Durieux
2026-09-13 08:42:55 +00:00
parent 22096a8346
commit 8efb9bd506
5 changed files with 100 additions and 4 deletions
+9 -2
View File
@@ -95,8 +95,15 @@ form. Existing installations have direct account-specific configuration links.
GitHub may require organization administrator approval. Use **Refresh access GitHub may require organization administrator approval. Use **Refresh access
after approval** on the Connections page when approval is delayed. after approval** on the Connections page when approval is delayed.
App-connected accounts default to the App for new repository/PR access. The App-connected accounts default to the App for new repository/PR access.
explicit **Use existing OAuth access** choice handles repositories not yet 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 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 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 OAuth, with the current repository permission scope explained. The form draft
+22 -2
View File
@@ -220,7 +220,19 @@ async function installationToken(binding: RepositoryAccess, ownerId: string): Pr
} }
export async function boundAppToken(ownerId: string, binding: RepositoryAccess): Promise<string> { export async function boundAppToken(ownerId: string, binding: RepositoryAccess): Promise<string> {
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<GitHubRepositoryInfo>(`/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); const userToken = await appUserToken(ownerId);
// User token checks the intersection of user and App rights on every access. // 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. // 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 }); const hasApp = config.GITHUB_APP_ENABLED && await CredentialModel.exists({ ownerId, provider: APP_PROVIDER });
if (choice === "github-app" || (choice === undefined && hasApp)) { if (choice === "github-app" || (choice === undefined && hasApp)) {
const repo = (await appRepositories(ownerId)).find(r => r.full_name.toLowerCase() === fullName.toLowerCase()); 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<GitHubRepositoryInfo>(
`/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() }; const binding: RepositoryAccess = { kind: "github-app", repositoryId: repo.id, installationId: repo.installationId, revision: randomUUID() };
return { binding, token: await boundAppToken(ownerId, binding) }; return { binding, token: await boundAppToken(ownerId, binding) };
} }
@@ -3,6 +3,7 @@ import { Schema } from "mongoose";
export const repositoryAccessSchema = new Schema({ export const repositoryAccessSchema = new Schema({
kind: { type: String, enum: ["oauth", "github-app"], required: true }, kind: { type: String, enum: ["oauth", "github-app"], required: true },
repositoryId: Number, repositoryId: Number,
publicRead: Boolean,
installationId: Number, installationId: Number,
revision: { type: String, required: true }, revision: { type: String, required: true },
}, { _id: false }); }, { _id: false });
+2
View File
@@ -2,6 +2,8 @@
export interface RepositoryAccess { export interface RepositoryAccess {
kind: "oauth" | "github-app"; kind: "oauth" | "github-app";
repositoryId?: number; repositoryId?: number;
/** App user access to a verified public repository, without an installation. */
publicRead?: boolean;
installationId?: number; installationId?: number;
revision: string; revision: string;
} }
+66
View File
@@ -197,6 +197,72 @@ describeMongo("GitHub App credential and repository integration", function () {
expect(selected.token).to.equal("legacy-secret"); expect(selected.token).to.equal("legacy-secret");
expect(selected.binding.kind).to.equal("oauth"); 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 () => { it("checks the user's access before minting a repository-restricted token", async () => {
await app.saveAppGrant(owner.id, data()); await app.saveAppGrant(owner.id, data());
const binding = { kind: "github-app", installationId: 4, repositoryId: 7, revision: "one" }; const binding = { kind: "github-app", installationId: 4, repositoryId: 7, revision: "one" };