mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-09-29 21:51:44 +02:00
fix: allow GitHub App access to public repositories without installation
This commit is contained in:
@@ -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
@@ -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,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
|||||||
Reference in New Issue
Block a user