mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-09-12 13:48:58 +02:00
feat: unify GitHub sign-in and connect OAuth for gists
This commit is contained in:
@@ -192,7 +192,7 @@ describeMongo("credential access (MongoDB)", function () {
|
||||
it("OAuth login writes a credential and returns a token-free session user", async () => {
|
||||
const passport = require("passport");
|
||||
require("../src/server/routes/connection");
|
||||
const result = await new Promise((resolve, reject) => passport._strategy("github")._verify("oauth-secret", "refresh-secret", {
|
||||
const result = await new Promise((resolve, reject) => passport._strategy("github")._verify({ githubOAuthContext: { expires: Date.now() + 60000 } }, "oauth-secret", "refresh-secret", {
|
||||
id: "external-test", username: owner.username, emails: [], photos: [],
|
||||
}, (error, user) => error ? reject(error) : resolve(user)));
|
||||
expect(JSON.stringify(result)).not.to.include("oauth-secret");
|
||||
|
||||
@@ -197,7 +197,7 @@ describe("frontend production regressions", function () {
|
||||
it("sends signed-out visitors to sign in instead of the dashboard", function () {
|
||||
const manage = f => f.key === "manage";
|
||||
const out = landing(null);
|
||||
expect(out.scope.featureHref(out.scope.features.find(manage))).to.equal("/github/login");
|
||||
expect(out.scope.featureHref(out.scope.features.find(manage))).to.equal("/signin");
|
||||
expect(out.scope.featureTarget(out.scope.features.find(manage))).to.equal("_self");
|
||||
const signedIn = landing({ username: "jane" });
|
||||
expect(signedIn.scope.featureHref(signedIn.scope.features.find(manage))).to.equal("/dashboard");
|
||||
|
||||
+63
-3
@@ -1,4 +1,5 @@
|
||||
const { expect } = require("chai");
|
||||
const { URL } = require("node:url");
|
||||
const { createHmac, generateKeyPairSync, createVerify } = require("crypto");
|
||||
const process = require("process");
|
||||
const { setTimeout } = require("timers");
|
||||
@@ -96,7 +97,7 @@ describe("GitHub App protocol boundaries", () => {
|
||||
const describeMongo = process.env.TEST_MONGODB_URI ? describe : describe.skip;
|
||||
describeMongo("GitHub App credential and repository integration", function () {
|
||||
this.timeout(15000);
|
||||
let owner, previousConfig, previousFetch, calls, server, base, session;
|
||||
let owner, previousConfig, previousFetch, calls, server, base, session, authenticated;
|
||||
const data = (suffix = "1", expires = 3600) => ({ access_token: "ghu_access" + suffix, refresh_token: "ghr_refresh" + suffix,
|
||||
expires_in: expires, refresh_token_expires_in: 100000 });
|
||||
before(async () => {
|
||||
@@ -111,13 +112,15 @@ describeMongo("GitHub App credential and repository integration", function () {
|
||||
api.use(express.json());
|
||||
api.use((req, _res, next) => {
|
||||
req.session = session;
|
||||
req.user = { user: owner };
|
||||
req.isAuthenticated = () => true;
|
||||
req.session.regenerate = done => done();
|
||||
req.user = authenticated ? { user: owner } : undefined;
|
||||
req.isAuthenticated = () => authenticated;
|
||||
req.login = (identity, done) => { req.user = identity; done(); };
|
||||
req.logout = done => done();
|
||||
next();
|
||||
});
|
||||
api.use("/github", githubAppRouter);
|
||||
api.use("/github", require("../src/server/routes/connection").router);
|
||||
server = await new Promise(resolve => { const listening = api.listen(0, "127.0.0.1", () => resolve(listening)); });
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
@@ -134,6 +137,7 @@ describeMongo("GitHub App credential and repository integration", function () {
|
||||
calls = [];
|
||||
await Credentials.deleteMany({}); await Users.deleteMany({}); await Installations.deleteMany({});
|
||||
app.clearAppTokenCache();
|
||||
authenticated = true;
|
||||
owner = await Users.create({ username: "owner", externalIDs: { github: "10" } });
|
||||
});
|
||||
afterEach(() => { globalThis.fetch = previousFetch; });
|
||||
@@ -249,6 +253,62 @@ describeMongo("GitHub App credential and repository integration", function () {
|
||||
expect(replay.status).to.equal(400);
|
||||
expect(calls).to.have.length(2);
|
||||
});
|
||||
it("signs an existing user in through the App without changing legacy access", async () => {
|
||||
authenticated = false;
|
||||
await setCredential(owner.id, "legacy-secret");
|
||||
session.githubAppFlow = { state: "state", expires: Date.now() + 60000, returnTo: "/dashboard" };
|
||||
mock(url => url.includes("/login/oauth/access_token") ? data() : { id: 10, login: "owner-renamed" });
|
||||
const result = await request("/github/app/callback?state=state&code=code");
|
||||
expect(result.location).to.equal("/dashboard");
|
||||
expect(await Users.countDocuments()).to.equal(1);
|
||||
expect(await getCredentialToken(owner.id)).to.equal("legacy-secret");
|
||||
expect(await app.appUserToken(owner.id)).to.equal("ghu_access1");
|
||||
});
|
||||
it("requires legacy verification for an older account before linking its GitHub ID", async () => {
|
||||
authenticated = false;
|
||||
await Users.updateOne({ _id: owner._id }, { $unset: { externalIDs: 1 } });
|
||||
session.githubAppFlow = { state: "state", expires: Date.now() + 60000, returnTo: "/gist-anonymize" };
|
||||
mock(url => url.includes("/login/oauth/access_token") ? data() : { id: 10, login: "owner" });
|
||||
const result = await request("/github/app/callback?state=state&code=code");
|
||||
expect(result.location).to.equal("/signin?recover=1");
|
||||
expect(session.githubRecovery.ownerId).to.equal(owner.id);
|
||||
expect(session.githubRecovery.githubId).to.equal("10");
|
||||
expect(await Credentials.countDocuments()).to.equal(0);
|
||||
expect((await Users.findById(owner.id)).externalIDs?.github).to.equal(undefined);
|
||||
const { verify } = require("../src/server/routes/connection");
|
||||
const identity = await new Promise((resolve, reject) => verify({ githubOAuthContext: session.githubRecovery },
|
||||
"verified-legacy", "", { id: "10", username: "owner" }, (error, user) => error ? reject(error) : resolve(user)));
|
||||
expect(identity.user.id).to.equal(owner.id);
|
||||
expect((await Users.findById(owner.id)).externalIDs.github).to.equal("10");
|
||||
expect(await getCredentialToken(owner.id)).to.equal("verified-legacy");
|
||||
});
|
||||
it("returns OAuth connections to the gist form and rejects callback replay", async () => {
|
||||
const strategy = require("passport")._strategy("github");
|
||||
const exchange = strategy._oauth2.getOAuthAccessToken;
|
||||
const profile = strategy.userProfile;
|
||||
strategy._oauth2.getOAuthAccessToken = (_code, _params, done) => done(null, "gist-oauth", "", {});
|
||||
strategy.userProfile = (_token, done) => done(null, { id: "10", username: "owner" });
|
||||
try {
|
||||
const start = await request("/github/login?returnTo=%2Fgist-anonymize%2Fsaved");
|
||||
const state = new URL(start.location).searchParams.get("state");
|
||||
const result = await request("/github/auth?code=test&state=" + state);
|
||||
expect(result.location).to.equal("/gist-anonymize/saved");
|
||||
expect(await getCredentialToken(owner.id)).to.equal("gist-oauth");
|
||||
expect((await request("/github/auth?code=test&state=" + state)).location).to.equal("/signin");
|
||||
await request("/github/login?returnTo=https%3A%2F%2Fevil.test");
|
||||
expect(session.githubOAuthFlow.returnTo).to.equal("/connections");
|
||||
} finally {
|
||||
strategy._oauth2.getOAuthAccessToken = exchange;
|
||||
strategy.userProfile = profile;
|
||||
}
|
||||
});
|
||||
it("rejects a different account during OAuth recovery before saving credentials", async () => {
|
||||
const { verify } = require("../src/server/routes/connection");
|
||||
await rejects(new Promise((resolve, reject) => verify({ githubOAuthContext: { ownerId: owner.id, githubId: "10", expires: Date.now() + 60000 } },
|
||||
"wrong-token", "", { id: "99", username: "owner" }, (error, user) => error ? reject(error) : resolve(user))), "github_identity_mismatch");
|
||||
expect(await Credentials.countDocuments()).to.equal(0);
|
||||
expect((await Users.findById(owner.id)).externalIDs.github).to.equal("10");
|
||||
});
|
||||
it("rejects linking a different GitHub identity", async () => {
|
||||
session.githubAppFlow = { state: "state", ownerId: owner.id, expires: Date.now() + 60000, returnTo: "/connections" };
|
||||
mock(url => url.includes("/login/oauth/access_token") ? data() : { id: 99, login: "other" });
|
||||
|
||||
@@ -146,7 +146,7 @@ describe("production regressions", function () {
|
||||
let calls = 0;
|
||||
stub(UserModel, "findOne", async () => ++calls === 1 ? null : { externalIDs: { github: "old-id" }, isAdmin: true });
|
||||
stub(UserModel, "updateOne", () => { throw new Error("must not overwrite identity"); });
|
||||
const error = await new Promise(resolve => passport._strategy("github")._verify("token", "", { id: "new-id", username: "recycled" }, resolve));
|
||||
const error = await new Promise(resolve => passport._strategy("github")._verify({ githubOAuthContext: { expires: Date.now() + 60000 } }, "token", "", { id: "new-id", username: "recycled" }, resolve));
|
||||
expect(error.message).to.equal("not_connected");
|
||||
});
|
||||
|
||||
|
||||
+35
-1
@@ -89,10 +89,44 @@ describe("Vue 3 UI", function () {
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("offers App and OAuth sign-in and direct repository access links", async function () {
|
||||
it("offers one App sign-in for new and existing accounts", async function () {
|
||||
ui = await browser("/signin", { "/api/options": { GITHUB_APP_ENABLED: true, GITHUB_OAUTH_ENABLED: true } });
|
||||
expect(ui.window.document.querySelector('a[href="/github/app/login"]')).not.to.equal(null);
|
||||
expect(ui.window.document.querySelector('a[href="/github/login"]')).to.equal(null);
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("shows previous-connection verification only for a pending recovery", async function () {
|
||||
ui = await browser("/signin?recover=1", {
|
||||
"/api/options": { GITHUB_APP_ENABLED: true, GITHUB_OAUTH_ENABLED: true },
|
||||
"/github/account-recovery": { required: true },
|
||||
});
|
||||
expect(ui.window.document.querySelector('a[href="/github/login?recover=1"]')).not.to.equal(null);
|
||||
expect(ui.window.document.querySelector('a[href="/github/app/login"]')).to.equal(null);
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("falls back to one OAuth sign-in when the App is disabled", async function () {
|
||||
ui = await browser("/signin", { "/api/options": { GITHUB_APP_ENABLED: false, GITHUB_OAUTH_ENABLED: true } });
|
||||
expect(ui.window.document.querySelector('a[href="/github/login"]')).not.to.equal(null);
|
||||
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 () {
|
||||
ui = await browser("/gist-anonymize", {
|
||||
"/api/user": { username: "owner" },
|
||||
"/github/connections": { appEnabled: true, appConnected: true, oauthEnabled: true, oauthConnected: false },
|
||||
});
|
||||
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"));
|
||||
expect(button).not.to.equal(undefined);
|
||||
expect(ui.requests.some(r => r.url.pathname === "/api/gist/source/311fc9")).to.equal(false);
|
||||
button.click();
|
||||
const saved = JSON.parse(ui.window.sessionStorage.getItem("github-access-draft"));
|
||||
expect(saved.path).to.equal("/gist-anonymize");
|
||||
expect(saved.draft.sourceUrl).to.equal("https://gist.github.com/311fc9");
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user