fix: support gists through GitHub App login (#875)

* fix: prompt OAuth reconnection when gist credentials are rejected

* fix: support gist reads through GitHub App user grants

* test: adapt truncated gist regression to provider selection

* fix: address gist reconnect visibility and token renewal review

* fix: verify App authorization before revoking OAuth
This commit is contained in:
Thomas Durieux authored and GitHub committed 2026-10-08 10:26:13 +01:00
1 parent 4fd31a59c8
commit db966356ec
14 files changed
+283 -54

No files matched your search

+59
View File
@@ -0,0 +1,59 @@
const { expect } = require("chai");
require("ts-node/register/transpile-only");
const github = require("../src/core/GitHubUtils");
const Gist = require("../src/core/Gist").default;
const Model = require("../src/core/model/anonymizedGists/anonymizedGists.model").default;
const { handleError } = require("../src/server/routes/route-utils");
describe("gist authentication errors", function () {
let original, gist, client;
beforeEach(function () {
original = github.octokit;
gist = new Gist(new Model({ source: { gistId: "example" }, gist: { files: [{ filename: "saved.txt", content: "saved" }] } }));
gist.getAccess = async () => ({ token: "rejected-token", connection: "oauth" });
client = { rest: { gists: { get: async () => ({ data: { files: {} } }) } }, paginate: async () => [], request: async () => ({ data: "raw" }) };
github.octokit = token => { expect(token).to.equal("rejected-token"); return client; };
});
afterEach(function () { github.octokit = original; });
for (const operation of ["metadata", "comments", "raw file"]) {
it("requests OAuth reconnection when GitHub rejects " + operation, async function () {
const failure = Object.assign(new Error("Bad credentials"), { status: 401 });
const reject = async () => { throw failure; };
if (operation === "metadata") client.rest.gists.get = reject;
if (operation === "comments") client.paginate = reject;
if (operation === "raw file") {
client.rest.gists.get = async () => ({ data: { files: { file: { filename: "large.txt", truncated: true, raw_url: "https://gist.githubusercontent.com/example/raw" } } } });
client.request = reject;
}
let error;
try { await gist.download(); } catch (caught) { error = caught; }
expect(error?.message).to.equal("github_oauth_required");
expect(error.cause).to.equal(failure);
let status, body;
handleError(error, { headersSent: false, status: value => { status = value; return { json: value => { body = value; } }; } });
expect(status).to.equal(403);
expect(body).to.deep.equal({ error: "github_oauth_required" });
expect(gist.model.gist.files[0].content).to.equal("saved");
});
}
for (const status of [403, 404, 429, 500]) {
it("preserves non-authentication GitHub errors: " + status, async function () {
const failure = Object.assign(new Error("Other GitHub failure"), { status });
client.rest.gists.get = async () => { throw failure; };
let error;
try { await gist.download(); } catch (caught) { error = caught; }
expect(error).to.equal(failure);
});
}
it("requests App reconnection when an App user token is rejected", async function () {
gist.getAccess = async () => ({ token: "rejected-token", connection: "github-app" });
client.rest.gists.get = async () => { throw Object.assign(new Error("Bad credentials"), { status: 401 }); };
let error;
try { await gist.download(); } catch (caught) { error = caught; }
expect(error?.message).to.equal("github_app_reconnect_required");
});
it("still downloads a gist when the credential is accepted", async function () {
await gist.download();
expect(gist.model.gist.files).to.have.length(0);
});
});
+105 -1
View File
@@ -175,7 +175,7 @@ describeMongo("GitHub App credential and repository integration", function () {
const result = String(url).endsWith("/token/scoped")
? scopeHandler ? await scopeHandler(body, options) : { status: 403 }
: await handler(String(url), options, body);
return new globalThis.Response(JSON.stringify(result.body || result), { status: result.status || 200, headers: { "content-type": "application/json" } });
return new globalThis.Response(JSON.stringify(result.body || result), { status: result.status || 200, headers: { "content-type": "application/json", ...result.headers } });
};
}
it("keeps OAuth and App grants separate, encrypted and migration-verifiable", async () => {
@@ -191,6 +191,68 @@ describeMongo("GitHub App credential and repository integration", function () {
expect(projected.encryptedToken).to.equal(undefined);
expect(await verifyCredentials(mongoose.connection.db, createTokenCipher(keys, "test"))).to.deep.equal({ checked: 2, legacy: 0 });
});
for (const legacy of [false, true]) {
it(`reads gist files and comments with an App user grant (legacy: ${legacy})`, async () => {
if (legacy) await setCredential(owner.id, "legacy-secret");
await app.saveAppGrant(owner.id, data("old", -1));
mock((url, options, body) => {
if (url.includes("/login/oauth/access_token")) {
expect(body.refresh_token).to.equal("ghr_refreshold");
return data("new");
}
expect(new globalThis.Headers(options.headers).get("authorization")).to.equal("token ghu_accessnew");
if (url.includes("/comments")) return [{ body: "comment", user: { login: "owner" } }];
expect(url).to.include("/gists/example");
return { description: "gist", files: { file: { filename: "file.txt", content: "hello" } } };
});
const Gist = require("../src/core/Gist").default;
const Model = require("../src/core/model/anonymizedGists/anonymizedGists.model").default;
const gist = new Gist(new Model({ owner: owner.id, source: { gistId: "example" } }));
await gist.download();
expect(gist.model.gist.files[0].content).to.equal("hello");
expect(gist.model.gist.comments[0].body).to.equal("comment");
expect(calls).to.have.length(3);
});
}
for (const rejected of [false, true]) {
it(`renews App gist access during comment pagination (401: ${rejected})`, async () => {
await app.saveAppGrant(owner.id, data("old"));
let pages = 0, refreshed = false;
mock(async (url, options) => {
if (url.includes("/login/oauth/access_token")) { refreshed = true; return data("new"); }
const authorization = new globalThis.Headers(options.headers).get("authorization");
if (url.includes("/comments")) {
pages++;
if (pages === 1) {
expect(authorization).to.equal("token ghu_accessold");
await Credentials.updateOne({ ownerId: owner.id, provider: app.APP_PROVIDER }, { $set: { expiresAt: new Date(0) } });
if (rejected) return { status: 401, body: { message: "Bad credentials" } };
return { body: [{ body: "first", user: { login: "owner" } }], headers: { link: '<https://api.github.com/gists/example/comments?page=2>; rel="next"' } };
}
expect(authorization).to.equal("token ghu_accessnew");
return [{ body: "last", user: { login: "owner" } }];
}
return { files: {} };
});
const Gist = require("../src/core/Gist").default;
const Model = require("../src/core/model/anonymizedGists/anonymizedGists.model").default;
const gist = new Gist(new Model({ owner: owner.id, source: { gistId: "example" } }));
await gist.download();
expect(refreshed).to.equal(true);
expect(pages).to.equal(2);
expect(gist.model.gist.comments.map(c => c.body)).to.deep.equal(rejected ? ["last"] : ["first", "last"]);
});
}
it("keeps legacy gist access but does not bypass revoked App grants", async () => {
await setCredential(owner.id, "legacy-secret");
const Gist = require("../src/core/Gist").default;
const Model = require("../src/core/model/anonymizedGists/anonymizedGists.model").default;
const gist = new Gist(new Model({ owner: owner.id, source: { gistId: "example" } }));
expect(await gist.getToken()).to.equal("legacy-secret");
await app.saveAppGrant(owner.id, data());
await Credentials.updateOne({ ownerId: owner.id, provider: app.APP_PROVIDER }, { $set: { revoked: true } });
await rejects(gist.download(), "github_app_reconnect_required");
});
it("serializes refresh and atomically rotates the token pair", async () => {
await app.saveAppGrant(owner.id, data("old", -1));
mock(async (_url, _options, body) => {
@@ -415,6 +477,48 @@ describeMongo("GitHub App credential and repository integration", function () {
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf }, body: body ? JSON.stringify(body) : undefined });
return { status: response.status, location: response.headers.get("location"), data: await response.json().catch(() => null) };
}
it("allows OAuth revocation with an existing gist and keeps gist access working", async () => {
await setCredential(owner.id, "legacy-secret");
await app.saveAppGrant(owner.id, data());
const Model = require("../src/core/model/anonymizedGists/anonymizedGists.model").default;
const model = await Model.create({ owner: owner.id, gistId: "oauth-revoke-gist", source: { gistId: "example" } });
mock((url, options, body) => {
if (url.includes("/user/installations")) {
expect(new globalThis.Headers(options.headers).get("authorization")).to.equal("Bearer ghu_access1");
return { installations: [] };
}
if (url.includes("/grant")) {
expect(options.method).to.equal("DELETE");
expect(body.access_token).to.equal("legacy-secret");
return {};
}
expect(new globalThis.Headers(options.headers).get("authorization")).to.equal("token ghu_access1");
return url.includes("/comments") ? [] : { files: { file: { filename: "file.txt", content: "still accessible" } } };
});
const result = await request("/github/connections/disconnect-oauth", {});
expect(result.status).to.equal(200);
expect(await getCredentialToken(owner.id)).not.to.be.ok;
const Gist = require("../src/core/Gist").default;
const gist = new Gist(model);
await gist.download();
expect(gist.model.gist.files[0].content).to.equal("still accessible");
});
for (const status of [401, 503]) {
it(`preserves OAuth when GitHub cannot verify the App grant (${status})`, async () => {
await setCredential(owner.id, "legacy-secret");
await app.saveAppGrant(owner.id, data());
mock((url, options) => {
expect(url).to.include("/user/installations");
expect(options.method).not.to.equal("DELETE");
return { status, body: { message: "unavailable" } };
});
const result = await request("/github/connections/disconnect-oauth", {});
expect(result.status).to.equal(status === 401 ? 403 : 502);
expect(result.data.error).to.equal(status === 401 ? "github_app_reconnect_required" : "github_unavailable");
expect(await getCredentialToken(owner.id)).to.equal("legacy-secret");
expect(calls).to.have.length(1);
});
}
it("links App authorization to the existing OAuth account without replacing its grant", async () => {
await setCredential(owner.id, "legacy-secret");
owner.isAdmin = true; await owner.save();
+1 -1
View File
@@ -166,7 +166,7 @@ describe("production regressions", function () {
it("fetches complete truncated gist content", async function () {
const Gist = require("../src/core/Gist").default;
const model = new (require("../src/core/model/anonymizedGists/anonymizedGists.model").default)({ source: { gistId: "123" } });
const gist = new Gist(model); gist.getToken = async () => "private-token";
const gist = new Gist(model); gist.getAccess = async () => ({ token: "private-token", connection: "github-app" });
stub(gh, "octokit", token => {
expect(token).to.equal("private-token");
return { rest: { gists: { get: async () => ({ data: { files: { file: { filename: "file", content: "prefix", truncated: true, raw_url: "https://gist.githubusercontent.com/raw", size: 20 } } } }) } },
+29 -4
View File
@@ -469,17 +469,23 @@ describe("Vue 3 UI", function () {
expect(ui.window.document.querySelector('a[href="/github/app/login"]')).to.equal(null);
});
it("offers OAuth for an App-only gist and saves the draft before connecting", async function () {
for (const provider of ["OAuth", "App"]) {
it(`offers ${provider} recovery when gist credentials are rejected`, async function () {
ui = await browser("/gist-anonymize", {
"/api/user": { username: "owner" },
"/github/connections": { appEnabled: true, appConnected: true, oauthEnabled: true, oauthConnected: false },
"/github/connections": { appEnabled: true, appConnected: provider === "App", oauthEnabled: true, oauthConnected: provider === "OAuth" },
"/api/gist/source/311fc9": { __status: 403, body: { error: provider === "App" ? "github_app_reconnect_required" : "github_oauth_required" } },
});
const input = await ui.input("#sourceUrl", "https://gist.github.com/311fc9");
input.dispatchEvent(new ui.window.Event("blur"));
await delay(60);
const button = [...ui.window.document.querySelectorAll("button")].find(node => node.textContent.includes("Connect GitHub OAuth to access gists"));
const button = [...ui.window.document.querySelectorAll("button")].find(node => node.textContent.includes(`Connect GitHub ${provider} to access gists`));
expect(button).not.to.equal(undefined);
expect(ui.requests.some(r => r.url.pathname === "/api/gist/source/311fc9")).to.equal(false);
expect(button.closest(".anonymize-workspace")).not.to.equal(null);
for (let node = button; node; node = node.parentElement) {
expect(ui.window.getComputedStyle(node).display, node.className).not.to.equal("none");
}
expect(ui.requests.some(r => r.url.pathname === "/api/gist/source/311fc9")).to.equal(true);
button.click();
const saved = JSON.parse(ui.window.sessionStorage.getItem("github-access-draft"));
expect(saved.path).to.equal("/gist-anonymize");
@@ -487,6 +493,25 @@ describe("Vue 3 UI", function () {
expect(ui.errors).to.deep.equal([]);
});
}
it("previews a gist with only an App login", async function () {
ui = await browser("/gist-anonymize", {
"/api/user": { username: "owner" },
"/github/connections": { appEnabled: true, appConnected: true, gistConnection: "github-app", oauthEnabled: true, oauthConnected: false },
"/api/user/default": { terms: [], options: { content: true } },
"/api/anonymize-preview": request => ({ contents: request.payload.contents }),
"/api/gist/source/311fc9": { gist: { description: "App gist", files: [{ filename: "example.txt", content: "example" }], comments: [] } },
});
const input = await ui.input("#sourceUrl", "https://gist.github.com/311fc9");
input.dispatchEvent(new ui.window.Event("blur"));
await delay(300);
expect(ui.requests.some(r => r.url.pathname === "/api/gist/source/311fc9")).to.equal(true);
expect(ui.window.document.body.textContent).to.include("example.txt");
expect(ui.window.document.body.textContent).not.to.include("Connect GitHub OAuth to access gists");
expect(ui.errors).to.deep.equal([]);
});
for (const status of ["archived", "preparing", "queue", "download", "removing", "expiring"]) {
it(`hides connection changes on ${status} resources`, async function () {
ui = await browser("/connections", {