Merge pull request #828 from tdurieux/fix/public-repository-app-access

fix: allow GitHub App access to public repositories without installation
This commit is contained in:
Thomas Durieux authored and GitHub committed 2026-09-13 19:06:25 +02:00
commit 8abdaf7119
8 files changed
+235 -16

No files matched your search

+1 -1
View File
@@ -304,7 +304,7 @@ export async function getToken(repository: Repository) {
}
}
if (repository.model.githubAccess?.kind === "github-app") {
return boundAppToken(repository.owner.id, repository.model.githubAccess);
return boundAppToken(repository.owner.id, repository.model.githubAccess, repository.model.source.repositoryName);
}
const credential = await getCredential(repository.owner.id);
const ownerAccessToken = credential?.token;
+1 -1
View File
@@ -26,7 +26,7 @@ export default class PullRequest {
}
async getToken() {
if (this._model.githubAccess?.kind === "github-app") return boundAppToken(this.owner.id, this._model.githubAccess);
if (this._model.githubAccess?.kind === "github-app") return boundAppToken(this.owner.id, this._model.githubAccess, this._model.source.repositoryFullName);
return (await getCredentialToken(this.owner.id, "github", { collection: "anonymizedpullrequests", id: this._model._id })) || config.GITHUB_TOKEN;
}
+64 -9
View File
@@ -1,5 +1,5 @@
import { registerGitHubToken } from "./github-token-context";
import { createSign, randomUUID } from "crypto";
import { createHash, createSign, randomUUID } from "crypto";
import { readFileSync } from "fs";
import config from "../config";
import AnonymousError from "./AnonymousError";
@@ -15,12 +15,12 @@ export function appError(code = "github_app_reconnect_required", status = 403) {
}
// Never expose upstream bodies, bearer credentials or signed URLs in errors.
export async function githubRequest<T>(path: string, token: string, method = "GET", body?: unknown): Promise<T> {
export async function githubRequest<T>(path: string, token: string, method = "GET", body?: unknown, scheme: "Bearer" | "Basic" = "Bearer"): Promise<T> {
if (!path.startsWith("/") || path.startsWith("//")) throw appError("invalid_github_path", 400);
let response: Response;
try {
response = await fetch(`https://api.github.com${path}`, {
method, headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`,
method, headers: { Accept: "application/vnd.github+json", Authorization: `${scheme} ${token}`,
"X-GitHub-Api-Version": "2022-11-28", "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(20000),
});
@@ -144,7 +144,7 @@ export async function reconcileInstallation(installationId: number, revision: st
}
export interface GitHubRepositoryInfo {
id: number; full_name: string; name: string; private: boolean; html_url: string; size: number;
id: number; full_name: string; name: string; private: boolean; visibility?: string; html_url: string; size: number;
default_branch: string; owner: { id: number; login: string };
}
export interface AppInstallation {
@@ -180,7 +180,7 @@ export async function appRepositories(ownerId: string) {
const installationTokens = new Map<string, { token: string; expires: number }>();
const minting = new Map<string, Promise<string>>();
export function clearAppTokenCache() { installationTokens.clear(); }
export function clearAppTokenCache() { installationTokens.clear(); publicTokens.clear(); }
async function installationToken(binding: RepositoryAccess, ownerId: string): Promise<string> {
const id = binding.installationId;
let local = await InstallationModel.findOne({ appId: config.GITHUB_APP_ID, installationId: id }).lean();
@@ -219,8 +219,55 @@ async function installationToken(binding: RepositoryAccess, ownerId: string): Pr
try { return await work; } finally { minting.delete(key); }
}
export async function boundAppToken(ownerId: string, binding: RepositoryAccess): Promise<string> {
if (!Number.isSafeInteger(binding.repositoryId) || !Number.isSafeInteger(binding.installationId)) throw appError();
const publicTokens = new Map<string, { token: string; expires: number }>();
const publicMinting = new Map<string, Promise<string>>();
async function scopedPublicToken(ownerId: string, repositoryId: number, sourceName: string, userToken: string, force: boolean) {
const key = `${ownerId}:${repositoryId}:${createHash("sha256").update(userToken).digest("hex")}`;
if (force) publicTokens.delete(key);
const cached = publicTokens.get(key);
if (cached && cached.expires > Date.now() + 60000) return cached.token;
if (publicMinting.has(key)) return publicMinting.get(key)!;
const mint = (async () => {
const basic = Buffer.from(`${config.GITHUB_APP_CLIENT_ID}:${config.GITHUB_APP_CLIENT_SECRET}`).toString("base64");
const issued = await githubRequest<{ token: string; expires_at?: string | null }>(
`/applications/${encodeURIComponent(config.GITHUB_APP_CLIENT_ID)}/token/scoped`, basic, "POST", {
access_token: userToken, target: sourceName.split("/")[0], repository_ids: [repositoryId],
permissions: { metadata: "read", contents: "read", pull_requests: "read", pages: "read" },
}, "Basic");
if (typeof issued.token !== "string" || !issued.token || issued.token === userToken) throw appError("github_app_access_required");
// An omitted expiration does not justify caching beyond the current read.
const expires = issued.expires_at ? Date.parse(issued.expires_at) : 0;
if (Number.isFinite(expires) && expires > Date.now() + 60000) {
if (publicTokens.size >= 1000) publicTokens.clear();
publicTokens.set(key, { token: issued.token, expires });
}
return issued.token;
})();
publicMinting.set(key, mint);
try { return await mint; } finally { publicMinting.delete(key); }
}
export async function boundAppToken(ownerId: string, binding: RepositoryAccess, sourceName?: string, force = false): Promise<string> {
if (!Number.isSafeInteger(binding.repositoryId)) throw appError();
if (binding.publicRead === true) {
if (binding.installationId !== undefined || !sourceName || !/^[^/\s]+\/[^/\s]+$/.test(sourceName)) throw appError();
const userToken = await appUserToken(ownerId);
// Source reads use owner/name, so validate that exact name against the
// bound ID. A replacement at a renamed repository's old URL must fail.
const repo = await githubRequest<GitHubRepositoryInfo>(
`/repos/${sourceName.split("/").map(encodeURIComponent).join("/")}`, 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 || repo.visibility !== "public") throw appError("github_app_access_required");
const token = await scopedPublicToken(ownerId, binding.repositoryId!, sourceName, userToken, force);
// Each repository gets a distinct bearer token, so concurrent public
// traversals can revalidate their own binding on every request.
registerGitHubToken(token, { quotaKey: `app-user:${ownerId}`,
renew: force => boundAppToken(ownerId, binding, sourceName, force) });
return token;
}
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,9 +286,17 @@ 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<GitHubRepositoryInfo>(
`/repos/${fullName.split("/").map(encodeURIComponent).join("/")}`, userToken);
if (publicRepo.private !== false || publicRepo.visibility !== "public" || !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, fullName) };
}
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, fullName) };
}
const token = await getCredentialToken(ownerId);
if (!token) throw appError("github_oauth_required");
@@ -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 });
+2
View File
@@ -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;
}