Merge pull request #814 from tdurieux/feat/github-app-dual-auth

Add read-only GitHub App access alongside OAuth
This commit is contained in:
Thomas Durieux
2026-09-10 06:27:36 -07:00
committed by GitHub
49 changed files with 1754 additions and 139 deletions
+32 -2
View File
@@ -2,6 +2,18 @@ import { resolve } from "path";
import { randomBytes } from "crypto";
interface Config {
GITHUB_APP_ENABLED: boolean;
GITHUB_APP_NEW_CONNECTIONS: boolean;
GITHUB_OAUTH_ENABLED: boolean;
GITHUB_APP_ID: string;
GITHUB_APP_SLUG: string;
GITHUB_APP_CLIENT_ID: string;
GITHUB_APP_CLIENT_SECRET: string;
GITHUB_APP_PRIVATE_KEY: string;
GITHUB_APP_PRIVATE_KEY_FILE: string;
GITHUB_APP_CALLBACK: string;
GITHUB_APP_WEBHOOK_SECRET: string;
CREDENTIAL_KEYS: string;
CREDENTIAL_ACTIVE_KEY_ID: string;
CREDENTIAL_LEGACY_READS: boolean;
@@ -55,6 +67,18 @@ interface Config {
RATE_LIMIT: number;
}
const config: Config = {
GITHUB_APP_ENABLED: false,
GITHUB_APP_NEW_CONNECTIONS: true,
GITHUB_OAUTH_ENABLED: true,
GITHUB_APP_ID: "",
GITHUB_APP_SLUG: "",
GITHUB_APP_CLIENT_ID: "",
GITHUB_APP_CLIENT_SECRET: "",
GITHUB_APP_PRIVATE_KEY: "",
GITHUB_APP_PRIVATE_KEY_FILE: "",
GITHUB_APP_CALLBACK: "http://localhost:5000/github/app/callback",
GITHUB_APP_WEBHOOK_SECRET: "",
// Predictable defaults are dangerous: a known SESSION_SECRET lets anyone
// forge session cookies. Default to empty and resolve below — random in
// dev, required in production. See the post-env block.
@@ -152,10 +176,10 @@ if (!config.SESSION_SECRET || config.SESSION_SECRET === "SESSION_SECRET") {
// Refuse to start in production with the placeholder OAuth credentials or the
// default database password baked into the image.
if (isProduction) {
const insecureDefaults: [string, string][] = [
const insecureDefaults: [string, string][] = config.GITHUB_OAUTH_ENABLED ? [
["CLIENT_ID", "CLIENT_ID"],
["CLIENT_SECRET", "CLIENT_SECRET"],
];
] : [];
if (!config.MONGODB_URI) {
insecureDefaults.push(["DB_PASSWORD", "password"]);
}
@@ -168,4 +192,10 @@ if (isProduction) {
}
}
if (config.GITHUB_APP_ENABLED) {
for (const name of ["GITHUB_APP_ID", "GITHUB_APP_SLUG", "GITHUB_APP_CLIENT_ID", "GITHUB_APP_CLIENT_SECRET", "GITHUB_APP_CALLBACK", "GITHUB_APP_WEBHOOK_SECRET"] as const) {
if (!config[name]) throw new Error(`${name} is required when GITHUB_APP_ENABLED=true`);
}
if (!config.GITHUB_APP_PRIVATE_KEY && !config.GITHUB_APP_PRIVATE_KEY_FILE) throw new Error("A GitHub App private key is required");
}
export default config;
+5
View File
@@ -1,3 +1,5 @@
import { APP_PROVIDER, appError } from "./github-app";
import CredentialModel from "./model/credentials/credentials.model";
import { getCredentialToken } from "./credentials";
import { RepositoryStatus } from "./types";
import User from "./User";
@@ -47,6 +49,9 @@ export default class Gist {
}
async getToken() {
if (config.GITHUB_APP_ENABLED && !(await getCredentialToken(this.owner.id)) && await CredentialModel.exists({ ownerId: this.owner.id, provider: APP_PROVIDER })) {
throw appError("github_oauth_required");
}
return (await getCredentialToken(this.owner.id, "github", { collection: "anonymizedgists", id: this._model._id })) || config.GITHUB_TOKEN;
}
+35 -5
View File
@@ -1,3 +1,7 @@
import AnonymizedRepositoryModel from "./model/anonymizedRepositories/anonymizedRepositories.model";
import { isConnected } from "../server/database";
import { githubQuotaKey, githubTokenContext } from "./github-token-context";
import { boundAppToken } from "./github-app";
import { Octokit } from "@octokit/rest";
import { throttling } from "@octokit/plugin-throttling";
import { createClient, RedisClientType } from "redis";
@@ -63,7 +67,7 @@ const ThrottledOctokit = Octokit.plugin(throttling);
const tokenGates = new Map<string, { resetAt: number }>();
function setTokenGate(token: string, retryAfterSec: number) {
const key = token.slice(-8);
const key = githubQuotaKey(token);
const resetAt = Date.now() + retryAfterSec * 1000;
const existing = tokenGates.get(key);
if (!existing || resetAt > existing.resetAt) {
@@ -95,7 +99,7 @@ export class RateLimitDelayError extends Error {
* Returns the reset timestamp, or 0 if no gate is active.
*/
export function getTokenGateResetAt(token: string): number {
const key = token.slice(-8);
const key = githubQuotaKey(token);
const gate = tokenGates.get(key);
if (!gate) return 0;
if (gate.resetAt <= Date.now()) {
@@ -106,7 +110,7 @@ export function getTokenGateResetAt(token: string): number {
}
async function waitForTokenGate(token: string): Promise<void> {
const key = token.slice(-8);
const key = githubQuotaKey(token);
const localGate = tokenGates.get(key);
let waitMs = 0;
let resetAt = 0;
@@ -208,8 +212,11 @@ export async function getRedisGateResetAt(tokenKey: string): Promise<number> {
}
export function octokit(token: string) {
const context = githubTokenContext(token);
const oct = new ThrottledOctokit({
auth: token,
// Managed App tokens are supplied by the renewal hook. Octokit's static
// token strategy would otherwise overwrite the renewed Authorization header.
auth: context ? undefined : token,
request: {
fetch: fetch,
},
@@ -240,6 +247,19 @@ export function octokit(token: string) {
},
},
});
if (context) {
oct.hook.before("request", async options => {
options.headers.authorization = `token ${await context.renew()}`;
});
oct.hook.wrap("request", async (request, options) => {
try { return await request(options); }
catch (error) {
if ((error as { status?: number }).status !== 401) throw error;
options.headers.authorization = `token ${await context.renew(true)}`;
return request(options);
}
});
}
oct.hook.error("request", (err) => {
if (isGitHubRateLimitError(err)) {
throw new AnonymousError("github_rate_limit_exceeded", {
@@ -258,7 +278,8 @@ export { waitForTokenGate };
export async function checkToken(token: string) {
const oct = octokit(token);
try {
await oct.users.getAuthenticated();
if (token.startsWith("ghs_")) await oct.request("GET /installation/repositories");
else await oct.users.getAuthenticated();
return true;
} catch (err) {
if (
@@ -276,6 +297,15 @@ const checkedRepositoryTokens = new WeakMap<Repository, string>();
export async function getToken(repository: Repository) {
repository.assertNotArchived();
logger.debug("getToken", { repoId: repository.repoId });
if (isConnected && !repository.model.isNew) {
const current = await AnonymizedRepositoryModel.findById(repository.model._id).select("owner githubAccess").lean();
if (!current || String(current.owner) !== repository.owner.id || current.githubAccess?.revision !== repository.model.githubAccess?.revision) {
throw new AnonymousError("connection_changed", { httpStatus: 409 });
}
}
if (repository.model.githubAccess?.kind === "github-app") {
return boundAppToken(repository.owner.id, repository.model.githubAccess);
}
const credential = await getCredential(repository.owner.id);
const ownerAccessToken = credential?.token;
if (ownerAccessToken) {
+2
View File
@@ -1,3 +1,4 @@
import { boundAppToken } from "./github-app";
import { getCredentialToken } from "./credentials";
import { RepositoryStatus } from "./types";
import User from "./User";
@@ -25,6 +26,7 @@ export default class PullRequest {
}
async getToken() {
if (this._model.githubAccess?.kind === "github-app") return boundAppToken(this.owner.id, this._model.githubAccess);
return (await getCredentialToken(this.owner.id, "github", { collection: "anonymizedpullrequests", id: this._model._id })) || config.GITHUB_TOKEN;
}
+14 -2
View File
@@ -483,6 +483,7 @@ export default class Repository {
status: { $nin: [RepositoryStatus.ARCHIVED, RepositoryStatus.REMOVING, RepositoryStatus.REMOVED,
RepositoryStatus.EXPIRING, RepositoryStatus.EXPIRED] },
anonymizeDate: this._model.anonymizeDate,
"githubAccess.revision": this._model.githubAccess?.revision || { $exists: false },
} : {}),
},
{ $set: { status, statusDate, statusMessage } }
@@ -508,8 +509,19 @@ export default class Repository {
/**
* Remove the repository
*/
async remove() {
await this.updateStatus(RepositoryStatus.REMOVING);
async remove(expected?: { accessRevision?: string }) {
if (expected) {
// Claim the lifecycle before deleting files; migration rejects REMOVING.
this.assertNotArchived();
const result = await AnonymizedRepositoryModel.updateOne({ _id: this.model._id,
status: this.model.status,
"githubAccess.revision": expected.accessRevision || { $exists: false },
}, { $set: { status: RepositoryStatus.REMOVING, statusDate: new Date() } });
if (!result.matchedCount) throw new AnonymousError("connection_changed", { httpStatus: 409 });
this.model.status = RepositoryStatus.REMOVING;
} else {
await this.updateStatus(RepositoryStatus.REMOVING);
}
await this.resetSate();
await this.updateStatus(RepositoryStatus.REMOVED);
}
+19 -1
View File
@@ -1,3 +1,6 @@
import config from "../config";
import { GitHubRepositoryInfo, APP_PROVIDER, appRepositories, appUserToken } from "./github-app";
import CredentialModel from "./model/credentials/credentials.model";
import { getCredentialToken } from "./credentials";
import AnonymizedRepositoryModel from "./model/anonymizedRepositories/anonymizedRepositories.model";
import RepositoryModel from "./model/repositories/repositories.model";
@@ -33,7 +36,9 @@ export default class User {
}
async getAccessToken(): Promise<string> {
return getCredentialToken(this.id);
const oauth = await getCredentialToken(this.id);
if (oauth || !config.GITHUB_APP_ENABLED) return oauth;
return appUserToken(this.id);
}
get photo(): string | undefined {
@@ -59,6 +64,19 @@ export default class User {
*/
force: boolean;
}): Promise<GitHubRepository[]> {
if (config.GITHUB_APP_ENABLED && await CredentialModel.exists({ ownerId: this.id, provider: APP_PROVIDER })) {
const oauth = await getCredentialToken(this.id);
let appRepos: GitHubRepositoryInfo[] = [];
try { appRepos = await appRepositories(this.id); }
catch (error) { if (!oauth) throw error; }
// Discovery may list the independently connected OAuth provider when the
// App is unavailable. Resource access never falls back between providers.
const legacy = oauth ? await octokit(oauth).paginate("GET /user/repos", { visibility: "all", per_page: 100 }) : [];
const repos = new Map<number, GitHubRepositoryInfo>(legacy.map(r => [r.id, r]));
for (const r of appRepos) repos.set(r.id, r);
return [...repos.values()].map(r => new GitHubRepository(new RepositoryModel({ externalId: "gh_" + r.id,
name: r.full_name, url: r.html_url, size: r.size, defaultBranch: r.default_branch })));
}
if (
!this._model.repositories ||
this._model.repositories.length == 0 ||
+6 -6
View File
@@ -28,19 +28,19 @@ export function createTokenCipher(rawKeys: string, activeKeyId: string) {
keys.set(id, Buffer.from(value, "base64"));
}
if (!keys.has(activeKeyId)) throw new Error("CREDENTIAL_ACTIVE_KEY_ID is missing from CREDENTIAL_KEYS");
const aad = (ownerId: string, provider: string) =>
Buffer.from(JSON.stringify(["credentials", ownerId, provider, "encryptedToken", 1]));
const aad = (ownerId: string, provider: string, purpose = "encryptedToken") =>
Buffer.from(JSON.stringify(["credentials", ownerId, provider, purpose, 1]));
return {
encrypt(token: string, ownerId: string, provider: string): EncryptedToken {
encrypt(token: string, ownerId: string, provider: string, purpose = "encryptedToken"): EncryptedToken {
if (!token) throw new Error("Cannot encrypt an empty credential");
const nonce = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", keys.get(activeKeyId)!, nonce);
cipher.setAAD(aad(ownerId, provider));
cipher.setAAD(aad(ownerId, provider, purpose));
const ciphertext = Buffer.concat([cipher.update(token, "utf8"), cipher.final()]);
return { version: 1, keyId: activeKeyId, nonce: nonce.toString("base64"),
ciphertext: ciphertext.toString("base64"), tag: cipher.getAuthTag().toString("base64") };
},
decrypt(value: EncryptedToken, ownerId: string, provider: string): string {
decrypt(value: EncryptedToken, ownerId: string, provider: string, purpose = "encryptedToken"): string {
try {
if (!value || value.version !== 1 || !keys.has(value.keyId)) throw new Error();
const decode = (s: string) => {
@@ -53,7 +53,7 @@ export function createTokenCipher(rawKeys: string, activeKeyId: string) {
const tag = decode(value.tag);
if (nonce.length !== 12 || tag.length !== 16) throw new Error();
const decipher = createDecipheriv("aes-256-gcm", keys.get(value.keyId)!, nonce, { authTagLength: 16 });
decipher.setAAD(aad(ownerId, provider));
decipher.setAAD(aad(ownerId, provider, purpose));
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(decode(value.ciphertext)), decipher.final()]).toString("utf8");
} catch {
+1 -1
View File
@@ -28,7 +28,7 @@ export async function getCredentialToken(ownerId: string, provider = "github", r
}): Promise<string> {
const credential = await getCredential(ownerId, provider);
if (credential) return credential.token;
if (config.CREDENTIAL_LEGACY_READS && resource) {
if (config.CREDENTIAL_LEGACY_READS && provider === "github" && resource) {
const row = await CredentialModel.db.collection(resource.collection).findOne({
_id: resource.id as Types.ObjectId,
owner: new Types.ObjectId(ownerId),
+258
View File
@@ -0,0 +1,258 @@
import { registerGitHubToken } from "./github-token-context";
import { createSign, randomUUID } from "crypto";
import { readFileSync } from "fs";
import config from "../config";
import AnonymousError from "./AnonymousError";
import CredentialModel from "./model/credentials/credentials.model";
import InstallationModel from "./model/github-installation";
import UserModel from "./model/users/users.model";
import { credentialCipher, getCredentialToken } from "./credentials";
import { RepositoryAccess } from "./repository-access.types";
export const APP_PROVIDER = "github-app-user";
export function appError(code = "github_app_reconnect_required", status = 403) {
return new AnonymousError(code, { httpStatus: status });
}
// 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> {
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}`,
"X-GitHub-Api-Version": "2022-11-28", "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(20000),
});
} catch { throw appError("github_unavailable", 502); }
if (!response.ok) {
const limited = response.status === 429 || (response.status === 403 &&
(response.headers.get("x-ratelimit-remaining") === "0" || response.headers.has("retry-after")));
throw appError(limited ? "github_rate_limit_exceeded" : response.status >= 500 ? "github_unavailable" :
response.status === 401 ? "github_app_reconnect_required" : "github_app_access_required",
limited ? 429 : response.status >= 500 ? 502 : 403);
}
if (response.status === 204) return undefined as T;
return await response.json() as T;
}
export function appJWT(now = Date.now()): string {
if (!config.GITHUB_APP_ENABLED) throw appError("github_app_disabled", 503);
const key = config.GITHUB_APP_PRIVATE_KEY || readFileSync(config.GITHUB_APP_PRIVATE_KEY_FILE, "utf8");
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
const payload = `${encode({ alg: "RS256", typ: "JWT" })}.${encode({ iat: Math.floor(now / 1000) - 60,
exp: Math.floor(now / 1000) + 540, iss: config.GITHUB_APP_CLIENT_ID })}`;
return `${payload}.${createSign("RSA-SHA256").update(payload).sign(key, "base64url")}`;
}
export interface AppTokenResponse {
access_token: string;
refresh_token: string;
expires_in: number;
refresh_token_expires_in: number;
}
export async function exchangeAppToken(values: Record<string, string>): Promise<AppTokenResponse> {
let response: Response;
try {
response = await fetch("https://github.com/login/oauth/access_token", {
method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({ ...values, client_id: config.GITHUB_APP_CLIENT_ID, client_secret: config.GITHUB_APP_CLIENT_SECRET }),
signal: AbortSignal.timeout(20000),
});
} catch { throw appError("github_unavailable", 502); }
if (response.status >= 500) throw appError("github_unavailable", 502);
if (response.status === 429) throw appError("github_rate_limit_exceeded", 429);
const data = await response.json().catch(() => null) as AppTokenResponse | null;
if (!response.ok || !data || typeof data.access_token !== "string" || !data.access_token ||
typeof data.refresh_token !== "string" || !data.refresh_token ||
!Number.isFinite(data.expires_in) || data.expires_in <= 0 ||
!Number.isFinite(data.refresh_token_expires_in) || data.refresh_token_expires_in <= 0) {
throw appError("github_app_reconnect_required", 401);
}
return data;
}
function tokenFields(ownerId: string, data: AppTokenResponse) {
const cipher = credentialCipher();
return { encryptedToken: cipher.encrypt(data.access_token, ownerId, APP_PROVIDER),
encryptedRefreshToken: cipher.encrypt(data.refresh_token, ownerId, APP_PROVIDER, "encryptedRefreshToken"),
expiresAt: new Date(Date.now() + data.expires_in * 1000),
refreshExpiresAt: new Date(Date.now() + data.refresh_token_expires_in * 1000),
revision: randomUUID(), revoked: false, updatedAt: new Date() };
}
export async function saveAppGrant(ownerId: string, data: AppTokenResponse) {
await CredentialModel.updateOne({ ownerId, provider: APP_PROVIDER }, {
$set: tokenFields(ownerId, data), $unset: { refreshLock: "", refreshLockUntil: "" },
}, { upsert: true, runValidators: true });
await UserModel.updateOne({ _id: ownerId }, { $set: { repositories: [] } });
}
export async function appUserToken(ownerId: string): Promise<string> {
if (!config.GITHUB_APP_ENABLED) throw appError("github_app_disabled", 503);
const user = await UserModel.findById(ownerId).select("status externalIDs").lean();
if (!user || user.status === "removed" || user.status === "banned") throw appError();
// MongoDB lock serializes refresh across API processes and streamers. Conditional
// writes cannot replace a newer login or revive a grant revoked during refresh.
for (let attempt = 0; attempt < 30; attempt++) {
const row = await CredentialModel.findOne({ ownerId, provider: APP_PROVIDER })
.select("+encryptedToken +encryptedRefreshToken").lean();
if (!row || row.revoked) throw appError();
if (row.expiresAt && row.expiresAt.getTime() > Date.now() + 60000) {
return credentialCipher().decrypt(row.encryptedToken, ownerId, APP_PROVIDER);
}
if (!row.encryptedRefreshToken || !row.refreshExpiresAt || row.refreshExpiresAt.getTime() <= Date.now()) throw appError();
const lock = randomUUID();
const acquired = await CredentialModel.updateOne({ _id: row._id, revision: row.revision, revoked: { $ne: true },
$or: [{ refreshLockUntil: { $exists: false } }, { refreshLockUntil: { $lt: new Date() } }] },
{ $set: { refreshLock: lock, refreshLockUntil: new Date(Date.now() + 30000) } });
if (!acquired.modifiedCount) { await new Promise(resolve => setTimeout(resolve, 1000)); continue; }
try {
const refreshed = await exchangeAppToken({ grant_type: "refresh_token",
refresh_token: credentialCipher().decrypt(row.encryptedRefreshToken, ownerId, APP_PROVIDER, "encryptedRefreshToken") });
const saved = await CredentialModel.updateOne({ _id: row._id, revision: row.revision, refreshLock: lock, revoked: { $ne: true } },
{ $set: tokenFields(ownerId, refreshed), $unset: { refreshLock: "", refreshLockUntil: "" } });
if (saved.modifiedCount) return refreshed.access_token;
} finally {
await CredentialModel.updateOne({ _id: row._id, refreshLock: lock }, { $unset: { refreshLock: "", refreshLockUntil: "" } });
}
}
throw appError("github_app_refresh_busy", 503);
}
// Replayed revocations must never invalidate a newer, working authorization.
export async function reconcileAppGrant(ownerId: string) {
const row = await CredentialModel.findOne({ ownerId, provider: APP_PROVIDER }).lean();
if (!row || row.revoked) return;
try {
const token = await appUserToken(ownerId);
await githubRequest("/user", token);
} catch (error) {
if (!(error instanceof Error) || error.message !== "github_app_reconnect_required") throw error;
await CredentialModel.updateOne({ _id: row._id, revision: row.revision },
{ $set: { revoked: true, revision: randomUUID() } });
}
}
// Pending reconciliation is durable and retried on access after upstream failures.
// The revision guard prevents an older response from undoing a newer webhook.
export async function reconcileInstallation(installationId: number, revision: string) {
const current = await githubRequest<AppInstallation>(`/app/installations/${installationId}`, appJWT());
if (String(current.app_id) !== config.GITHUB_APP_ID) throw appError();
await InstallationModel.updateOne({ appId: config.GITHUB_APP_ID, installationId, revision },
{ $set: { blocked: !!current.suspended_at, reconciliationPending: false,
accountId: current.account.id, accountLogin: current.account.login, accountType: current.account.type,
checkedAt: new Date(), revision: randomUUID() } });
}
export interface GitHubRepositoryInfo {
id: number; full_name: string; name: string; private: boolean; html_url: string; size: number;
default_branch: string; owner: { id: number; login: string };
}
export interface AppInstallation {
id: number; app_id: number; suspended_at: string | null;
account: { id: number; login: string; type: string };
permissions: Record<string, string>;
}
export async function userInstallations(ownerId: string): Promise<AppInstallation[]> {
const token = await appUserToken(ownerId);
const result: AppInstallation[] = [];
for (let page = 1; ; page++) {
const data = await githubRequest<{ installations: AppInstallation[] }>(`/user/installations?per_page=100&page=${page}`, token);
result.push(...data.installations.filter(i => String(i.app_id) === config.GITHUB_APP_ID));
if (data.installations.length < 100) break;
}
return result;
}
export async function appRepositories(ownerId: string) {
const installations = await userInstallations(ownerId);
const token = await appUserToken(ownerId);
const results: (GitHubRepositoryInfo & { installationId: number })[] = [];
for (const installation of installations) {
if (installation.suspended_at) continue;
for (let page = 1; ; page++) {
const data = await githubRequest<{ repositories: GitHubRepositoryInfo[] }>(
`/user/installations/${installation.id}/repositories?per_page=100&page=${page}`, token);
results.push(...data.repositories.map(r => ({ ...r, installationId: installation.id })));
if (data.repositories.length < 100) break;
}
}
return results;
}
const installationTokens = new Map<string, { token: string; expires: number }>();
const minting = new Map<string, Promise<string>>();
export function clearAppTokenCache() { installationTokens.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();
if (local?.reconciliationPending && local.revision) {
await reconcileInstallation(id!, local.revision);
local = await InstallationModel.findOne({ appId: config.GITHUB_APP_ID, installationId: id }).lean();
}
if (local?.blocked) throw appError("github_app_access_required");
const key = `${ownerId}:${id}:${binding.repositoryId}:${local?.revision || ""}`;
const cached = installationTokens.get(key);
if (cached && cached.expires > Date.now() + 60000) return cached.token;
if (minting.has(key)) return minting.get(key)!;
const work = (async () => {
const jwt = appJWT();
const installation = await githubRequest<AppInstallation>(`/app/installations/${id}`, jwt);
if (String(installation.app_id) !== config.GITHUB_APP_ID || installation.suspended_at || installation.permissions.contents !== "read") {
throw appError("github_app_access_required");
}
// Refuse accidentally configured write permissions instead of presenting a
// misleading read-only connection to the user.
if (Object.values(installation.permissions).some(p => p === "write" || p === "admin")) throw appError("github_app_permissions_invalid");
const permissions: Record<string, string> = { contents: "read", metadata: "read" };
for (const p of ["pull_requests", "pages"]) if (installation.permissions[p] === "read") permissions[p] = "read";
const issued = await githubRequest<{ token: string; expires_at: string }>(`/app/installations/${id}/access_tokens`, jwt, "POST",
{ repository_ids: [binding.repositoryId], permissions });
const expires = Date.parse(issued.expires_at);
if (!issued.token || !Number.isFinite(expires)) throw appError();
// Bound memory and ensure a revocation that races minting is observed before use.
if (installationTokens.size >= 1000) installationTokens.clear();
const current = await InstallationModel.findOne({ appId: config.GITHUB_APP_ID, installationId: id }).lean();
if (current?.blocked || current?.revision !== local?.revision) throw appError("github_app_access_required");
installationTokens.set(key, { token: issued.token, expires });
return issued.token;
})();
minting.set(key, work);
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 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.
await githubRequest<GitHubRepositoryInfo>(`/repositories/${binding.repositoryId}`, userToken);
const token = await installationToken(binding, ownerId);
registerGitHubToken(token, { quotaKey: `installation:${binding.installationId}`, renew: async (force) => {
if (force) clearAppTokenCache();
return boundAppToken(ownerId, binding);
} });
return token;
}
export async function selectRepositoryAccess(ownerId: string, fullName: string, choice?: unknown): Promise<{ token: string; binding: RepositoryAccess }> {
if (!/^[^/\s]+\/[^/\s]+$/.test(fullName)) throw appError("repo_not_found", 400);
if (choice !== undefined && choice !== "oauth" && choice !== "github-app") throw appError("invalid_connection", 400);
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");
const binding: RepositoryAccess = { kind: "github-app", repositoryId: repo.id, installationId: repo.installationId, revision: randomUUID() };
return { binding, token: await boundAppToken(ownerId, binding) };
}
const token = await getCredentialToken(ownerId);
if (!token) throw appError("github_oauth_required");
return { token, binding: { kind: "oauth", revision: randomUUID() } };
}
export function installationURL(targetId?: number, repositoryIds: number[] = []) {
const base = `https://github.com/apps/${encodeURIComponent(config.GITHUB_APP_SLUG)}/installations/new`;
if (!targetId || !repositoryIds.length) return base;
const url = new URL(`${base}/permissions`);
url.searchParams.set("suggested_target_id", String(targetId));
for (const id of repositoryIds.slice(0, 100)) url.searchParams.append("repository_ids[]", String(id));
return url.toString();
}
+12
View File
@@ -0,0 +1,12 @@
import { createHash } from "crypto";
interface TokenContext { quotaKey: string; renew: (force?: boolean) => Promise<string>; }
const contexts = new Map<string, TokenContext>();
export function registerGitHubToken(token: string, context: TokenContext) {
if (contexts.size >= 2000 && !contexts.has(token)) contexts.delete(contexts.keys().next().value!);
contexts.set(token, context);
}
export function githubTokenContext(token: string) { return contexts.get(token); }
export function githubQuotaKey(token: string) {
return contexts.get(token)?.quotaKey || createHash("sha256").update(token).digest("hex").slice(0, 24);
}
+1
View File
@@ -145,6 +145,7 @@ export async function verifyCredentials(db: mongo.Db, cipher: Cipher) {
let checked = 0;
for await (const row of db.collection("credentials").find({})) {
cipher.decrypt(row.encryptedToken as EncryptedToken, String(row.ownerId), row.provider);
if (row.encryptedRefreshToken) cipher.decrypt(row.encryptedRefreshToken as EncryptedToken, String(row.ownerId), row.provider, "encryptedRefreshToken");
if (!(await db.collection("users").findOne({ _id: row.ownerId, status: { $ne: "removed" } }))) {
throw new Error("Credential has no active owner");
}
@@ -1,3 +1,4 @@
import { repositoryAccessSchema } from "../repository-access.schema";
import { Schema } from "mongoose";
const AnonymizedPullRequestSchema = new Schema({
@@ -15,6 +16,7 @@ const AnonymizedPullRequestSchema = new Schema({
lastView: Date,
pageView: Number,
owner: { type: Schema.Types.ObjectId, index: true },
githubAccess: { type: repositoryAccessSchema, default: undefined },
conference: String,
source: {
pullRequestId: Number,
@@ -1,3 +1,4 @@
import { RepositoryAccess } from "../../repository-access.types";
import { Document, Model } from "mongoose";
import { RepositoryStatus } from "../../types";
@@ -13,6 +14,7 @@ export interface IAnonymizedPullRequest {
accessToken?: string;
};
owner: string;
githubAccess?: RepositoryAccess;
conference: string;
options: {
terms: string[];
@@ -1,3 +1,4 @@
import { repositoryAccessSchema } from "../repository-access.schema";
import { Schema } from "mongoose";
const AnonymizedRepositorySchema = new Schema({
@@ -31,6 +32,7 @@ const AnonymizedRepositorySchema = new Schema({
addedAt: { type: Date, default: Date.now },
},
],
githubAccess: { type: repositoryAccessSchema, default: undefined },
conference: String,
source: {
type: { type: String },
@@ -1,3 +1,4 @@
import { RepositoryAccess } from "../../repository-access.types";
import { Document, Model } from "mongoose";
import { RepositoryStatus } from "../../types";
@@ -20,6 +21,7 @@ export interface IAnonymizedRepository {
accessToken?: string;
};
owner: string;
githubAccess?: RepositoryAccess;
coauthors?: {
username: string;
githubId?: string;
@@ -6,6 +6,13 @@ export interface ICredential {
provider: string;
encryptedToken: EncryptedToken;
updatedAt: Date;
encryptedRefreshToken?: EncryptedToken;
expiresAt?: Date;
refreshExpiresAt?: Date;
refreshLock?: string;
refreshLockUntil?: Date;
revision?: string;
revoked?: boolean;
}
const envelope = new Schema({
version: { type: Number, required: true, enum: [1] },
@@ -16,9 +23,16 @@ const envelope = new Schema({
}, { _id: false });
const schema = new Schema<ICredential>({
ownerId: { type: Schema.Types.ObjectId, required: true, ref: "user" },
provider: { type: String, required: true, enum: ["github"] },
provider: { type: String, required: true, enum: ["github", "github-app-user"] },
encryptedToken: { type: envelope, required: true, select: false },
updatedAt: { type: Date, required: true },
encryptedRefreshToken: { type: envelope, select: false },
expiresAt: Date,
refreshExpiresAt: Date,
refreshLock: { type: String, select: false },
refreshLockUntil: Date,
revision: String,
revoked: Boolean,
}, { collection: "credentials" });
schema.index({ ownerId: 1, provider: 1 }, { unique: true });
export default model<ICredential>("Credential", schema);
+15
View File
@@ -0,0 +1,15 @@
import { model, Schema } from "mongoose";
const schema = new Schema({
appId: { type: String, required: true },
installationId: { type: Number, required: true },
accountId: Number,
accountLogin: String,
accountType: String,
blocked: { type: Boolean, default: false },
reconciliationPending: { type: Boolean, default: false },
checkedAt: Date,
revision: String,
});
schema.index({ appId: 1, installationId: 1 }, { unique: true });
export default model("GitHubInstallation", schema);
@@ -0,0 +1,8 @@
import { Schema } from "mongoose";
export const repositoryAccessSchema = new Schema({
kind: { type: String, enum: ["oauth", "github-app"], required: true },
repositoryId: Number,
installationId: Number,
revision: { type: String, required: true },
}, { _id: false });
+2 -2
View File
@@ -1,8 +1,8 @@
const sensitive = /^(?:authorization|proxy-authorization|cookie|set-cookie|token|access_?tokens?|refresh_?token|encryptedToken|ciphertext|nonce|tag|password|client_?secret|CREDENTIAL_KEYS)$/i;
const sensitive = /^(?:authorization|proxy-authorization|cookie|set-cookie|token|access_?tokens?|refresh_?token|encryptedToken|encryptedRefreshToken|private_?key|GITHUB_APP_PRIVATE_KEY|GITHUB_APP_CLIENT_SECRET|GITHUB_APP_WEBHOOK_SECRET|ciphertext|nonce|tag|password|client_?secret|CREDENTIAL_KEYS)$/i;
export function redactSecrets(value: unknown, seen = new WeakSet<object>()): unknown {
if (typeof value === "string") return value
.replace(/\b(?:gh[pousr]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+)\b/g, "[REDACTED]")
.replace(/((?:access_token|refresh_token|token)=)[^&\s]+/gi, "$1[REDACTED]")
.replace(/((?:access_token|refresh_token|token|code|state)=)[^&\s]+/gi, "$1[REDACTED]")
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9+/=._-]+/gi, "$1 [REDACTED]");
if (!value || typeof value !== "object" || value instanceof Date) return value;
if (seen.has(value)) return "[Circular]";
+7
View File
@@ -0,0 +1,7 @@
/** A resource connection, never a bearer credential. Missing means legacy OAuth. */
export interface RepositoryAccess {
kind: "oauth" | "github-app";
repositoryId?: number;
installationId?: number;
revision: string;
}
+6 -5
View File
@@ -376,11 +376,12 @@ export async function getRepositoryFromGitHub(opt: {
| RestEndpointMethodTypes["repos"]["getPages"]["response"]["data"]["source"]
| undefined;
if (r.has_pages) {
const ghPageRes = await oct.repos.getPages({
owner: opt.owner,
repo: opt.repo,
});
pageSource = ghPageRes.data.source;
try {
const ghPageRes = await oct.repos.getPages({ owner: opt.owner, repo: opt.repo });
pageSource = ghPageRes.data.source;
} catch (error) {
if (![403, 404].includes((error as { status?: number }).status || 0)) throw error;
}
}
if (!isConnected) {
+8 -2
View File
@@ -1,3 +1,4 @@
import { githubQuotaKey } from "../../core/github-token-context";
import { SandboxedJob } from "bullmq";
import { config } from "dotenv";
config();
@@ -27,8 +28,13 @@ export default async function (job: SandboxedJob<RepoJobData, void>) {
if ([RepositoryStatus.ARCHIVED, RepositoryStatus.REMOVING, RepositoryStatus.REMOVED,
RepositoryStatus.EXPIRING, RepositoryStatus.EXPIRED].some((status) => status === repo.status)) return;
repo.protectLifecycle = true;
const token = await getToken(repo);
const tokenKey = token.slice(-8);
let token: string;
try { token = await getToken(repo); }
catch (error) {
await repo.updateStatus(RepositoryStatus.ERROR, error instanceof Error ? error.message : "github_app_reconnect_required");
throw error;
}
const tokenKey = githubQuotaKey(token);
const gateResetAt = await getRedisGateResetAt(tokenKey);
if (gateResetAt > 0) {
+2
View File
@@ -1,3 +1,4 @@
import InstallationModel from "../core/model/github-installation";
import { credentialCipher } from "../core/credentials";
import CredentialModel from "../core/model/credentials/credentials.model";
import mongoose, { ConnectOptions } from "mongoose";
@@ -31,6 +32,7 @@ export async function connect() {
if (!config.MONGODB_URI) options.authSource = "admin";
await mongoose.connect(getMongoUrl(), options);
await CredentialModel.createIndexes();
if (config.GITHUB_APP_ENABLED) await InstallationModel.createIndexes();
isConnected = true;
return database;
+3
View File
@@ -1,3 +1,4 @@
import { githubAppRouter, githubAppWebhook } from "./routes/github-app";
import { config as dotenv } from "dotenv";
dotenv();
@@ -100,6 +101,7 @@ function indexResponse(req: express.Request, res: express.Response) {
export default async function start() {
const app = express();
app.set("query parser", "extended");
app.use("/github/app/webhook", githubAppWebhook);
app.use(express.json());
// Preserve the empty body used by API validation when no JSON was parsed.
app.use((req, _res, next) => {
@@ -241,6 +243,7 @@ export default async function start() {
next();
});
app.use("/github", rate, speedLimiter, githubAppRouter);
app.use("/github", rate, speedLimiter, connectionRouter);
// api routes
+17 -4
View File
@@ -117,12 +117,14 @@ const verify = async (
}
};
passport.use(
if (config.GITHUB_OAUTH_ENABLED) passport.use(
new Strategy(
{
clientID: config.CLIENT_ID,
clientSecret: config.CLIENT_SECRET,
callbackURL: config.AUTH_CALLBACK,
// passport-oauth2 supports boolean session state; github2 types incorrectly narrow it.
state: true as unknown as string,
},
verify
)
@@ -174,6 +176,7 @@ export const router = express.Router();
router.get(
"/login",
(req, res, next) => config.GITHUB_OAUTH_ENABLED ? next() : res.status(503).json({ error: "github_oauth_disabled" }),
passport.authenticate("github", { scope: ["repo"] }), // Note the scope here
function (req: express.Request, res: express.Response) {
res.redirect("/");
@@ -182,9 +185,19 @@ router.get(
router.get(
"/auth",
passport.authenticate("github", { failureRedirect: "/" }),
function (req: express.Request, res: express.Response) {
res.redirect("/");
(req, res, next) => {
if (!config.GITHUB_OAUTH_ENABLED) return res.status(503).json({ error: "github_oauth_disabled" });
const existingId = (req.user as { user?: { id?: string } } | undefined)?.user?.id;
passport.authenticate("github", (error: Error | null, identity: Express.User | false) => {
if (error) return next(error);
if (!identity) return res.redirect("/signin");
const id = (identity as { user?: { id?: string } }).user?.id;
if (existingId && id !== existingId) return res.status(409).json({ error: "github_identity_mismatch" });
req.login(identity, loginError => {
if (loginError) return next(loginError);
res.redirect(existingId ? "/connections" : "/dashboard");
});
})(req, res, next);
}
);
+261
View File
@@ -0,0 +1,261 @@
import * as express from "express";
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "crypto";
import config from "../../config";
import UserModel from "../../core/model/users/users.model";
import CredentialModel from "../../core/model/credentials/credentials.model";
import InstallationModel from "../../core/model/github-installation";
import RepositoryModel from "../../core/model/anonymizedRepositories/anonymizedRepositories.model";
import PullRequestModel from "../../core/model/anonymizedPullRequests/anonymizedPullRequests.model";
import GistModel from "../../core/model/anonymizedGists/anonymizedGists.model";
import { getCredentialToken } from "../../core/credentials";
import { APP_PROVIDER, appError, appUserToken, AppInstallation, clearAppTokenCache, exchangeAppToken,
githubRequest, GitHubRepositoryInfo, reconcileAppGrant, reconcileInstallation, installationURL, saveAppGrant, selectRepositoryAccess, userInstallations } from "../../core/github-app";
import { getUser, handleError } from "./route-utils";
import { isDisabledAccount } from "./auth-utils";
type Flow = { state: string; expires: number; ownerId?: string; returnTo: string; repository?: string; install?: boolean };
declare module "express-session" {
interface SessionData { githubAppFlow?: Flow; githubInstallFlow?: Flow; githubConnectionCSRF?: string; }
}
export function safeReturnTo(value: unknown): string {
return typeof value === "string" && /^\/(?:(?:anonymize|pull-request-anonymize|gist-anonymize)(?:\/[\w-]+)?|connections|dashboard)(?:\?[^\\\r\n]*)?$/.test(value) ? value : "/connections";
}
export function consumeFlow(flow: Flow | undefined, state: unknown): Flow {
if (!flow || typeof state !== "string" || flow.state !== state || flow.expires < Date.now()) throw appError("invalid_auth_state", 400);
return flow;
}
function newFlow(ownerId: string | undefined, returnTo: unknown): Flow {
return { state: randomBytes(32).toString("hex"), expires: Date.now() + 10 * 60000, ownerId, returnTo: safeReturnTo(returnTo) };
}
function saveSession(req: express.Request) { return new Promise<void>((resolve, reject) => req.session.save(err => err ? reject(err) : resolve())); }
function enabled(req: express.Request, res: express.Response, next: express.NextFunction) {
if (!config.GITHUB_APP_ENABLED || !config.GITHUB_APP_NEW_CONNECTIONS) return res.status(503).json({ error: "github_app_disabled" });
next();
}
export const githubAppRouter = express.Router();
const router = githubAppRouter;
router.use((_req, res, next) => { res.set("Cache-Control", "no-store"); next(); });
router.get("/app/login", enabled, async (req, res) => {
try {
const ownerId = req.isAuthenticated() ? (await getUser(req)).id : undefined;
const flow = newFlow(ownerId, req.query.returnTo);
flow.install = req.query.install === "1";
if (typeof req.query.repository === "string" && /^[\w.-]+\/[\w.-]+$/.test(req.query.repository)) flow.repository = req.query.repository;
req.session.githubAppFlow = flow;
await saveSession(req);
const url = new URL("https://github.com/login/oauth/authorize");
url.searchParams.set("client_id", config.GITHUB_APP_CLIENT_ID);
url.searchParams.set("redirect_uri", config.GITHUB_APP_CALLBACK);
url.searchParams.set("state", flow.state);
res.redirect(url.toString());
} catch (error) { handleError(error, res, req); }
});
router.get("/app/callback", enabled, async (req, res) => {
try {
const pending = req.session.githubAppFlow;
delete req.session.githubAppFlow;
await saveSession(req);
const flow = consumeFlow(pending, req.query.state);
if (typeof req.query.code !== "string" || req.query.error) throw appError("github_app_authorization_cancelled", 400);
const tokens = await exchangeAppToken({ code: req.query.code, redirect_uri: config.GITHUB_APP_CALLBACK });
const profile = await githubRequest<{ id: number; login: string; avatar_url?: string }>("/user", tokens.access_token);
if (!Number.isSafeInteger(profile.id) || !profile.login) throw appError();
let user = await UserModel.findOne({ "externalIDs.github": String(profile.id) });
if (flow.ownerId) {
const current = await getUser(req);
if (current.id !== flow.ownerId || current.model.externalIDs?.github !== String(profile.id) || !user || user.id !== current.id) {
throw appError("github_identity_mismatch", 409);
}
} else if (req.isAuthenticated()) {
if (!user || user.id !== (await getUser(req)).id) throw appError("github_identity_mismatch", 409);
}
if (!user) {
// A matching login name alone is not proof of account ownership.
if (await UserModel.exists({ username: profile.login })) throw appError("github_account_link_required", 409);
user = new UserModel({ username: profile.login, externalIDs: { github: String(profile.id) }, photo: profile.avatar_url, emails: [] });
await user.save();
}
if (isDisabledAccount(user.status)) throw appError("not_connected", 403);
await saveAppGrant(user.id, tokens);
await new Promise<void>((resolve, reject) => req.login({ username: user!.username, user }, err => err ? reject(err) : resolve()));
res.redirect(flow.install ? `/github/app/install?returnTo=${encodeURIComponent(flow.returnTo)}&repository=${encodeURIComponent(flow.repository || "")}` : flow.returnTo);
} catch (error) { handleError(error, res, req); }
});
router.get("/app/install", enabled, async (req, res) => {
try {
const user = await getUser(req);
await appUserToken(user.id); // Authorization and installation are separate.
const flow = newFlow(user.id, req.query.returnTo);
req.session.githubInstallFlow = flow;
let target = installationURL();
const installations = await userInstallations(user.id);
if (typeof req.query.installationId === "string") {
const installation = installations.find(i => String(i.id) === req.query.installationId);
if (!installation) throw appError("github_app_access_required");
target = installation.account.type === "Organization"
? `https://github.com/organizations/${encodeURIComponent(installation.account.login)}/settings/installations/${installation.id}`
: `https://github.com/settings/installations/${installation.id}`;
} else if (typeof req.query.repository === "string" && /^[\w.-]+\/[\w.-]+$/.test(req.query.repository)) {
// Existing OAuth grants can preselect a private repo during initial migration.
const token = await getCredentialToken(user.id) || await appUserToken(user.id);
try {
const repo = await githubRequest<GitHubRepositoryInfo>(`/repos/${req.query.repository}`, token);
const existing = installations.find(i => i.account.id === repo.owner.id);
target = existing ? (existing.account.type === "Organization"
? `https://github.com/organizations/${encodeURIComponent(existing.account.login)}/settings/installations/${existing.id}`
: `https://github.com/settings/installations/${existing.id}`) : installationURL(repo.owner.id, [repo.id]);
} catch { /* A new private repository may be invisible until installation. */ }
}
const url = new URL(target);
url.searchParams.set("state", flow.state);
await saveSession(req);
res.redirect(url.toString());
} catch (error) { handleError(error, res, req); }
});
router.get("/app/setup", enabled, async (req, res) => {
try {
const pending = req.session.githubInstallFlow;
delete req.session.githubInstallFlow;
await saveSession(req);
if (!req.isAuthenticated()) return res.redirect("/github/app/login");
const user = await getUser(req);
// GitHub-initiated installs or configuration pages may not return state.
// Without returned state, never attach anything based on installation_id.
// A recent flow owned by this session is safe to use only for local navigation.
if (!req.query.state) return res.redirect(pending?.ownerId === user.id && pending.expires > Date.now()
? safeReturnTo(pending.returnTo) : "/connections");
const flow = consumeFlow(pending, req.query.state);
if (flow.ownerId !== user.id) throw appError("invalid_auth_state", 400);
if (req.query.setup_action !== "request") {
const installations = await userInstallations(user.id);
if (!installations.some(i => String(i.id) === req.query.installation_id)) throw appError("github_app_access_required");
}
await UserModel.updateOne({ _id: user.id }, { $set: { repositories: [] } });
res.redirect(flow.returnTo);
} catch (error) { handleError(error, res, req); }
});
router.get("/connections", async (req, res) => {
try {
const user = await getUser(req);
req.session.githubConnectionCSRF ||= randomBytes(32).toString("hex");
await saveSession(req);
const credentials = await CredentialModel.find({ ownerId: user.id }).select("provider revoked").lean();
const appConnected = credentials.some(c => c.provider === APP_PROVIDER && !c.revoked);
let installations: { id: number; account: string; suspended: boolean }[] = [];
let appErrorCode: string | undefined;
if (appConnected && config.GITHUB_APP_ENABLED) {
try {
const verified = await userInstallations(user.id);
installations = verified.map(i => ({ id: i.id, account: i.account.login, suspended: !!i.suspended_at }));
}
catch (error) { appErrorCode = error instanceof Error ? error.message : "github_app_reconnect_required"; }
}
const repos = await RepositoryModel.find({ owner: user.id, status: { $ne: "removed" } }).select("repoId source.repositoryName githubAccess status").lean();
const prs = await PullRequestModel.find({ owner: user.id, status: { $ne: "removed" } }).select("pullRequestId source.repositoryFullName githubAccess status").lean();
const gistCount = await GistModel.countDocuments({ owner: user.id, status: { $ne: "removed" } });
res.json({ csrf: req.session.githubConnectionCSRF, appEnabled: config.GITHUB_APP_ENABLED && config.GITHUB_APP_NEW_CONNECTIONS,
oauthEnabled: config.GITHUB_OAUTH_ENABLED, oauthConnected: !!(await getCredentialToken(user.id)), appConnected, appError: appErrorCode,
installations, gistCount, resources: [
...repos.map(r => ({ type: "repository", id: r.repoId, name: r.source.repositoryName, connection: r.githubAccess?.kind || "oauth", status: r.status })),
...prs.map(r => ({ type: "pull-request", id: r.pullRequestId, name: r.source.repositoryFullName, connection: r.githubAccess?.kind || "oauth", status: r.status })),
] });
} catch (error) { handleError(error, res, req); }
});
router.use("/connections", (req, res, next) => {
if (req.method !== "GET" && (!req.session.githubConnectionCSRF || req.headers["x-csrf-token"] !== req.session.githubConnectionCSRF)) {
return res.status(403).json({ error: "invalid_auth_state" });
}
next();
});
router.post("/connections/migrate", async (req, res) => {
try {
const user = await getUser(req);
const { type, id, connection, preview } = req.body;
if (typeof id !== "string" || !["repository", "pull-request"].includes(type) || !["oauth", "github-app"].includes(connection)) throw appError("invalid_connection", 400);
if (connection === "github-app" && (!config.GITHUB_APP_ENABLED || !config.GITHUB_APP_NEW_CONNECTIONS)) throw appError("github_app_disabled", 503);
const isRepo = type === "repository";
const model = isRepo ? await RepositoryModel.findOne({ repoId: id, owner: user.id }) : await PullRequestModel.findOne({ pullRequestId: id, owner: user.id });
if (!model || ["removed", "archived"].includes(model.status || "")) throw appError("repo_not_found", 404);
if (["preparing", "removing", "expiring"].includes(model.status || "")) throw appError("repository_busy", 409);
const source = model.source as { repositoryName?: string; repositoryFullName?: string; commit?: string; pullRequestId?: number };
const name = source.repositoryName || source.repositoryFullName || "";
const selected = await selectRepositoryAccess(user.id, name, connection);
const parts = name.split("/").map(encodeURIComponent).join("/");
await githubRequest(`/repos/${parts}/${isRepo ? `commits/${encodeURIComponent(source.commit || "")}` : `pulls/${source.pullRequestId}`}`, selected.token);
if (preview === true) return res.json({ eligible: true, connection });
const filter = { _id: model._id, owner: user.id, status: model.status, source: model.source,
githubAccess: model.githubAccess ? model.githubAccess : { $exists: false } };
const change = { $set: { githubAccess: selected.binding } };
const result = isRepo ? await RepositoryModel.updateOne(filter, change) : await PullRequestModel.updateOne(filter, change);
if (!result.modifiedCount) throw appError("connection_changed", 409);
res.json({ connection });
} catch (error) { handleError(error, res, req); }
});
export async function revokeGrant(ownerId: string, provider: "github" | "github-app-user") {
const token = await getCredentialToken(ownerId, provider);
const clientId = provider === "github" ? config.CLIENT_ID : config.GITHUB_APP_CLIENT_ID;
const clientSecret = provider === "github" ? config.CLIENT_SECRET : config.GITHUB_APP_CLIENT_SECRET;
if (token) {
const response = await fetch(`https://api.github.com/applications/${clientId}/grant`, { method: "DELETE",
headers: { Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`, "Content-Type": "application/json", Accept: "application/vnd.github+json" },
body: JSON.stringify({ access_token: token }), signal: AbortSignal.timeout(20000) });
if (!response.ok && response.status !== 404 && response.status !== 422) throw appError("github_grant_revocation_failed", 502);
}
await CredentialModel.deleteMany({ ownerId, provider });
if (provider === "github") await UserModel.updateOne({ _id: ownerId }, { $unset: { "accessTokens.github": "", "accessTokenDates.github": "" } });
await UserModel.updateOne({ _id: ownerId }, { $set: { repositories: [] } });
}
router.post("/connections/disconnect-oauth", async (req, res) => {
try {
const user = await getUser(req);
if (!(await CredentialModel.exists({ ownerId: user.id, provider: APP_PROVIDER, revoked: { $ne: true } }))) throw appError("another_login_required", 409);
await appUserToken(user.id);
const active = { owner: user.id, status: { $ne: "removed" }, "githubAccess.kind": { $ne: "github-app" } };
if (await RepositoryModel.exists(active) || await PullRequestModel.exists(active) || await GistModel.exists({ owner: user.id, status: { $ne: "removed" } })) {
throw appError("oauth_resources_remaining", 409);
}
await revokeGrant(user.id, "github");
res.json({ disconnected: true });
} catch (error) { handleError(error, res, req); }
});
export function validWebhookSignature(body: Buffer, signature: unknown, secret: string): boolean {
if (!secret || typeof signature !== "string" || !/^sha256=[a-f0-9]{64}$/.test(signature)) return false;
const expected = createHmac("sha256", secret).update(body).digest();
return timingSafeEqual(expected, Buffer.from(signature.slice(7), "hex"));
}
export const githubAppWebhook = express.Router();
githubAppWebhook.post("/", express.raw({ type: "application/json", limit: "2mb" }), async (req, res) => {
if (!config.GITHUB_APP_ENABLED || !Buffer.isBuffer(req.body) || !validWebhookSignature(req.body, req.headers["x-hub-signature-256"], config.GITHUB_APP_WEBHOOK_SECRET)) {
return res.status(401).json({ error: "invalid_webhook_signature" });
}
try {
const body = JSON.parse(req.body.toString("utf8"));
const event = req.headers["x-github-event"];
if (event === "github_app_authorization" && body.action === "revoked" && body.sender?.id) {
const users = await UserModel.find({ "externalIDs.github": String(body.sender.id) }).select("_id").lean();
for (const user of users) await reconcileAppGrant(String(user._id));
}
if (["installation", "installation_repositories"].includes(String(event)) && Number.isSafeInteger(body.installation?.id) && String(body.installation.app_id) === config.GITHUB_APP_ID) {
const installation = body.installation as AppInstallation;
// Fail closed immediately. Reconcile from GitHub, never trust event order
// to reactivate an installation. Duplicate events are safe to replay.
const filter = { appId: config.GITHUB_APP_ID, installationId: installation.id };
const revision = randomUUID();
await InstallationModel.updateOne(filter, { $set: { blocked: true, revision,
reconciliationPending: body.action !== "deleted" } }, { upsert: true });
clearAppTokenCache();
if (body.action !== "deleted") await reconcileInstallation(installation.id, revision);
}
return res.status(204).end();
} catch { return res.status(503).json({ error: "webhook_processing_failed" }); }
});
+2
View File
@@ -4,6 +4,8 @@ export const router = express.Router();
router.get("/", async (req: express.Request, res: express.Response) => {
res.json({
GITHUB_APP_ENABLED: config.GITHUB_APP_ENABLED && config.GITHUB_APP_NEW_CONNECTIONS,
GITHUB_OAUTH_ENABLED: config.GITHUB_OAUTH_ENABLED,
ENABLE_DOWNLOAD: config.ENABLE_DOWNLOAD,
MAX_FILE_SIZE: config.MAX_FILE_SIZE,
MAX_REPO_SIZE: config.MAX_REPO_SIZE,
+8 -3
View File
@@ -1,3 +1,4 @@
import { selectRepositoryAccess } from "../../core/github-app";
import * as express from "express";
import { ensureAuthenticated } from "./connection";
@@ -105,9 +106,11 @@ router.get(
async (req, res) => {
try {
const user = await getUser(req);
const access = await selectRepositoryAccess(user.id, `${req.params.owner}/${req.params.repository}`, req.query.connection);
const pullRequest = new PullRequest(
new AnonymizedPullRequestModel({
owner: user.id,
githubAccess: access.binding,
source: {
pullRequestId: parseInt(req.params.pullRequestId),
repositoryFullName: `${req.params.owner}/${req.params.repository}`,
@@ -116,7 +119,7 @@ router.get(
);
pullRequest.owner = user;
await pullRequest.download();
res.json(pullRequest.toJSON());
res.json({ ...pullRequest.toJSON(), connection: pullRequest.model.githubAccess?.kind || "oauth" });
} catch (error) {
handleError(error, res, req);
}
@@ -133,7 +136,7 @@ router.get(
const user = await getUser(req);
isOwnerOrAdmin([pullRequest.owner.id], user);
res.json(pullRequest.toJSON());
res.json({ ...pullRequest.toJSON(), connection: pullRequest.model.githubAccess?.kind || "oauth" });
} catch (error) {
handleError(error, res, req);
}
@@ -240,7 +243,7 @@ router.post(
).exec();
await pullRequest.updateStatus(RepositoryStatus.PREPARING);
await pullRequest.updateIfNeeded({ force: true });
res.json(pullRequest.toJSON());
res.json({ ...pullRequest.toJSON(), connection: pullRequest.model.githubAccess?.kind || "oauth" });
} catch (error) {
return handleError(error, res, req);
}
@@ -264,6 +267,8 @@ router.post("/", async (req, res) => {
pullRequest.model.pullRequestId = pullRequestUpdate.pullRequestId;
pullRequest.model.anonymizeDate = new Date();
pullRequest.model.owner = user.id;
const access = await selectRepositoryAccess(user.id, pullRequestUpdate.source.repositoryFullName, pullRequestUpdate.connection);
pullRequest.model.githubAccess = access.binding;
updatePullRequestModel(pullRequest.model, pullRequestUpdate);
pullRequest.source.pullRequestId = pullRequestUpdate.source.pullRequestId;
+43 -52
View File
@@ -1,4 +1,6 @@
import { getCredentialToken } from "../../core/credentials";
import { randomUUID } from "crypto";
import { githubQuotaKey } from "../../core/github-token-context";
import { selectRepositoryAccess, boundAppToken, appError } from "../../core/github-app";
import * as express from "express";
import { ensureAuthenticated } from "./connection";
@@ -19,10 +21,9 @@ import ConferenceModel from "../../core/model/conference/conferences.model";
import AnonymousError from "../../core/AnonymousError";
import { addRemovalJob, downloadQueue } from "../../queue";
import RepositoryModel from "../../core/model/repositories/repositories.model";
import User from "../../core/User";
import { RepositoryStatus } from "../../core/types";
import { checkToken, octokit, getRedisGateResetAt, getToken } from "../../core/GitHubUtils";
import { createLogger, serializeError } from "../../core/logger";
import { octokit, getRedisGateResetAt, getToken } from "../../core/GitHubUtils";
import { createLogger } from "../../core/logger";
const logger = createLogger("route:repo");
@@ -31,27 +32,15 @@ const router = express.Router();
// user needs to be connected for all user API
router.use(ensureAuthenticated);
async function getTokenForAdmin(user: User, req: express.Request) {
if (user.isAdmin) {
try {
const existingRepo = await AnonymizedRepositoryModel.findOne(
{
"source.repositoryName": `${req.params.owner}/${req.params.repo}`,
},
{
owner: 1,
}
);
if (existingRepo?.owner) {
const token = await getCredentialToken(String(existingRepo.owner), "github", {
collection: "anonymizedrepositories", id: existingRepo._id,
});
if (token && await checkToken(token)) return token;
}
} catch (error) {
logger.warn("getToken lookup failed", serializeError(error));
}
async function previewToken(req: express.Request) {
const user = await getUser(req);
if (typeof req.query.anonymizedRepoId === "string") {
const resource = await db.getRepository(req.query.anonymizedRepoId);
isOwnerCoauthorOrAdmin(resource, user);
if (resource.model.source.repositoryName?.toLowerCase() !== `${req.params.owner}/${req.params.repo}`.toLowerCase()) throw appError("repo_not_found", 404);
return getToken(resource);
}
return (await selectRepositoryAccess(user.id, `${req.params.owner}/${req.params.repo}`, req.query.connection)).token;
}
// claim a repository
@@ -86,11 +75,12 @@ router.post("/claim", async (req, res) => {
httpStatus: 404,
});
}
const selectedAccess = await selectRepositoryAccess(user.id, `${r.owner}/${r.name}`, req.body.connection);
const repo = await getRepositoryFromGitHub({
owner: r.owner,
repo: r.name,
repositoryID: req.query.repositoryID as string,
accessToken: await user.getAccessToken(),
accessToken: selectedAccess.token,
});
if (!repo) {
throw new AnonymousError("repo_not_found", {
@@ -118,7 +108,7 @@ router.post("/claim", async (req, res) => {
await AnonymizedRepositoryModel.updateOne(
{ repoId: repoConfig.repoId },
{ $set: { owner: user.model.id } }
{ $set: { owner: user.model.id, githubAccess: selectedAccess.binding } }
).collation({ locale: "en", strength: 2 });
return res.send("Ok");
} catch (error) {
@@ -245,11 +235,7 @@ router.get(
"/:owner/:repo/",
async (req, res) => {
try {
const user = await getUser(req);
let token = await user.getAccessToken();
if (user.isAdmin) {
token = (await getTokenForAdmin(user, req)) || token;
}
const token = await previewToken(req);
const repo = await getRepositoryFromGitHub({
owner: req.params.owner,
repo: req.params.repo,
@@ -268,11 +254,7 @@ router.get(
"/:owner/:repo/branches",
async (req, res) => {
try {
const user = await getUser(req);
let token = await user.getAccessToken();
if (user.isAdmin) {
token = (await getTokenForAdmin(user, req)) || token;
}
const token = await previewToken(req);
const repository = await getRepositoryFromGitHub({
accessToken: token,
owner: req.params.owner,
@@ -296,11 +278,7 @@ router.get(
"/:owner/:repo/readme",
async (req, res) => {
try {
const user = await getUser(req);
let token = await user.getAccessToken();
if (user.isAdmin) {
token = (await getTokenForAdmin(user, req)) || token;
}
const token = await previewToken(req);
const repo = await getRepositoryFromGitHub({
owner: req.params.owner,
@@ -347,8 +325,13 @@ router.get("/:repoId/", async (req, res) => {
: fullRepo.owner.id === user.model.id
? "owner"
: "coauthor";
const repoToken = await getToken(fullRepo);
const gateResetAt = await getRedisGateResetAt(repoToken.slice(-8));
json.connection = fullRepo.model.githubAccess?.kind || "oauth";
// Connection diagnostics must remain available even when access is revoked.
let gateResetAt = 0;
try {
const repoToken = await getToken(fullRepo);
gateResetAt = await getRedisGateResetAt(githubQuotaKey(repoToken));
} catch (error) { json.connectionError = error instanceof Error ? error.message : "github_app_reconnect_required"; }
if (gateResetAt > 0) {
json.rateLimitResetAt = gateResetAt;
}
@@ -493,6 +476,7 @@ router.post(
// needed when the underlying snapshot moves. Other edits (e.g. turning
// off auto-update — see #360) just persist and return.
const sourceChanged = hasRepositorySourceChanged(repo.model, repoUpdate);
const previousAccessRevision = repo.model.githubAccess?.revision;
updateRepoModel(repo.model, repoUpdate);
const reactivating = shouldReactivateInactiveRepository(repo.model);
@@ -504,32 +488,35 @@ router.post(
if (sourceChanged) {
const parsedRepository = gh(repoUpdate.fullName);
if (!parsedRepository?.owner || !parsedRepository?.name) {
await repo.resetSate(RepositoryStatus.ERROR, "repo_not_found");
throw new AnonymousError("repo_not_found", {
object: req.body,
httpStatus: 404,
});
}
if (repoUpdate.fullName !== repo.model.source.repositoryName && user.id !== repo.owner.id) throw appError("not_owner", 403);
const sourceAccess = repo.model.githubAccess?.kind === "github-app" && repoUpdate.fullName === repo.model.source.repositoryName
? { token: await boundAppToken(repo.owner.id, repo.model.githubAccess), binding: repo.model.githubAccess }
: await selectRepositoryAccess(repo.owner.id, `${parsedRepository.owner}/${parsedRepository.name}`, repo.model.githubAccess?.kind || "oauth");
const repository = await getRepositoryFromGitHub({
accessToken: await user.getAccessToken(),
accessToken: sourceAccess.token,
owner: parsedRepository.owner,
repo: parsedRepository.name,
});
if (!repository) {
await repo.resetSate(RepositoryStatus.ERROR, "repo_not_found");
throw new AnonymousError("repo_not_found", {
object: req.body,
httpStatus: 404,
});
}
await repository.getCommitInfo(repoUpdate.source.commit, {
accessToken: await user.getAccessToken(),
accessToken: sourceAccess.token,
});
repo.model.githubAccess = { ...sourceAccess.binding, revision: randomUUID() };
repo.model.source.repositoryId = repository.model.id;
repo.model.source.repositoryName =
repository.fullName || repoUpdate.fullName;
repo.model.anonymizeDate = new Date();
await repo.remove();
await repo.remove({ accessRevision: previousAccessRevision });
}
const removeRepoFromConference = async (conferenceID: string) => {
@@ -581,17 +568,19 @@ router.post(
}
}
repo.model.conference = repoUpdate.conference;
await AnonymizedRepositoryModel.updateOne(
{ _id: repo.model._id },
const saved = await AnonymizedRepositoryModel.updateOne(
{ _id: repo.model._id, "githubAccess.revision": previousAccessRevision || { $exists: false } },
{
$set: {
options: repo.model.options,
source: repo.model.source,
githubAccess: repo.model.githubAccess,
conference: repo.model.conference,
anonymizeDate: repo.model.anonymizeDate,
},
}
).exec();
if (!saved.matchedCount) throw appError("connection_changed", 409);
if (!sourceChanged && !reactivating) {
return res.json({ status: repo.status });
}
@@ -637,8 +626,9 @@ router.post("/", async (req, res) => {
httpStatus: 404,
});
}
const selectedAccess = await selectRepositoryAccess(user.id, `${r.owner}/${r.name}`, repoUpdate.connection);
const repository = await getRepositoryFromGitHub({
accessToken: await user.getAccessToken(),
accessToken: selectedAccess.token,
owner: r.owner,
repo: r.name,
});
@@ -651,13 +641,14 @@ router.post("/", async (req, res) => {
}
await repository.getCommitInfo(repoUpdate.source.commit, {
accessToken: await user.getAccessToken(),
accessToken: selectedAccess.token,
});
const repo = new AnonymizedRepositoryModel();
repo.repoId = repoUpdate.repoId;
repo.anonymizeDate = new Date();
repo.owner = user.id;
repo.githubAccess = selectedAccess.binding;
updateRepoModel(repo, repoUpdate);
repo.source.type = "GitHubStream";
+5 -16
View File
@@ -1,6 +1,6 @@
import { revokeGrant } from "./github-app";
import CredentialModel from "../../core/model/credentials/credentials.model";
import * as express from "express";
import got from "got";
import config from "../../config";
import { ensureAuthenticated } from "./connection";
import { handleError, getUser, isOwnerOrAdmin } from "./route-utils";
@@ -190,21 +190,10 @@ router.delete("/", async (req, res) => {
).exec(),
]);
// Revoke the OAuth grant so the application no longer appears in the
// user's GitHub authorized applications. Best-effort: the account is
// scrubbed even if GitHub rejects the revocation.
try {
await got.delete(
`https://api.github.com/applications/${config.CLIENT_ID}/grant`,
{
username: config.CLIENT_ID,
password: config.CLIENT_SECRET,
headers: { accept: "application/vnd.github+json" },
json: { access_token: await user.getAccessToken() },
}
);
} catch (error) {
logger.warn("oauth grant revocation failed", serializeError(error));
// Removing one account must not uninstall a shared organization installation.
for (const provider of ["github", "github-app-user"] as const) {
try { await revokeGrant(user.id, provider); }
catch (error) { logger.warn("grant revocation failed", serializeError(error)); }
}
await CredentialModel.deleteMany({ ownerId: user.model._id });