mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-10-01 14:19:34 +02:00
feat: add read-only GitHub App access alongside OAuth
This commit is contained in:
1 parent
e5c44845f0
commit
a60ad6a921
49 files changed
+1754
-139
No files matched your search
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 ||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 });
|
||||
@@ -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]";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user