feat: unify GitHub sign-in and connect OAuth for gists

This commit is contained in:
tdurieux
2026-09-10 18:20:56 +02:00
parent 82510628f7
commit 99a50cad7b
16 changed files with 251 additions and 53 deletions
+14 -5
View File
@@ -77,10 +77,15 @@ Do not rerun plaintext cleanup just to enable the App.
## User flow
Sign in offers App and OAuth choices. Both resolve the same existing account by
its GitHub numeric user ID. A signed-in user cannot attach a different GitHub
identity. Legacy accounts without a verified GitHub ID require account recovery;
App login never automatically links by username or email.
Sign in offers one GitHub App button and resolves existing accounts by GitHub
numeric user ID. Signing in preserves existing OAuth credentials and resource
bindings. If the App is disabled, the button uses OAuth instead.
An older account without a GitHub ID receives a one-time recovery prompt. The
user verifies the previous OAuth connection, then resumes App authorization.
Recovery is bound to the expected account and GitHub ID and expires after ten
minutes. A signed-in user cannot attach a different GitHub identity. App login
never automatically links by username or email.
On the anonymization form, **Connect read-only GitHub access** starts user
authorization and then repository installation. **Allow repository access on
@@ -93,7 +98,11 @@ after approval** on the Connections page when approval is delayed.
App-connected accounts default to the App for new repository/PR access. The
explicit **Use existing OAuth access** choice handles repositories not yet
available through the App. An App error never silently selects OAuth. Gists
continue using OAuth in this release.
continue using OAuth. An App-only user entering a gist URL is prompted to connect
OAuth, with the current repository permission scope explained. The form draft
is saved for 30 minutes and restored after authorization. Users with an existing
OAuth connection can use it immediately. OAuth remains available on the
Connections page, separate from sign-in.
**GitHub connections** lists each resource's current connection. First check
read-only access, then switch the resource. The switch validates the existing
+1 -1
View File
@@ -1,6 +1,6 @@
{
"core.min.js": "core.c5bd53363a.min.js",
"vendor.min.js": "vendor.cc9fc42458.min.js",
"vendor.min.js": "vendor.3188bc70a6.min.js",
"mermaid.min.js": "mermaid.f848a72d16.min.js",
"all.min.css": "all.87fb103470.min.css",
"markdown.min.js": "markdown.ad7b1d71c3.min.js",
+1 -1
View File
@@ -130,7 +130,7 @@
"invalid_webhook_signature": "The webhook signature is invalid.",
"webhook_processing_failed": "Webhook processing failed. Retry the delivery.",
"github_app_authorization_cancelled": "GitHub authorization was cancelled.",
"github_account_link_required": "This username belongs to an existing account. Sign in with legacy GitHub OAuth to keep access to your existing anonymizations, then connect the read-only GitHub App from GitHub connections.",
"github_account_link_required": "Verify your existing account with your previous GitHub connection. Future sign-ins will use the GitHub App.",
"github_app_refresh_busy": "GitHub credentials are being renewed. Please retry shortly.",
"github_grant_revocation_failed": "Unable to revoke the GitHub grant. Please retry.",
"another_login_required": "Connect and verify another sign-in method before disconnecting OAuth.",
+6
View File
@@ -24,6 +24,12 @@
<small class="form-text" style="color: var(--ink-muted);">
Repository, pull request (…/pull/42) and gist (gist.github.com/…) URLs are all accepted.
</small>
<aside class="alert alert-info" role="status" v-if="detectedType === 'gist' &amp;&amp; gistOAuthRequired">
<p>Connect GitHub OAuth to access gists. Your account and existing anonymizations stay the same, and your draft will be restored when you return.</p>
<p>GitHub's legacy OAuth connection includes write access to private repositories.</p>
<button type="button" class="btn btn-ink" @click="connectGistOAuth()" v-if="githubConnections?.oauthEnabled">Connect GitHub OAuth to access gists</button>
<p v-else>GitHub OAuth is currently unavailable. Please try again later.</p>
</aside>
<aside class="repo-access-details" aria-label="GitHub access" v-if="!sourceUrl &amp;&amp; githubConnections?.appEnabled &amp;&amp; githubConnections.appConnected &amp;&amp; githubConnections.oauthConnected">
<div class="repo-access-main">
<div class="repo-access-controls">
+12 -7
View File
@@ -7,8 +7,13 @@
<p class="paper-page-lede">Sign in to create and manage your anonymizations.</p>
</header>
<div class="auth-options">
<p class="auth-existing" role="note" v-if="site_options?.GITHUB_APP_ENABLED &amp;&amp; site_options?.GITHUB_OAUTH_ENABLED">Already have an account? Sign in with <a target="_self" href="/github/login">legacy GitHub OAuth</a> to keep access to your anonymizations. You can connect the read-only App from GitHub connections after signing in.</p>
<section v-if="site_options?.GITHUB_APP_ENABLED">
<section v-if="accountRecovery">
<h2>Verify your existing account</h2>
<p>We found an existing account. Verify it with your previous GitHub connection to keep your anonymizations. Future sign-ins will use the GitHub App.</p>
<a class="btn btn-ink auth-continue" target="_self" href="/github/login?recover=1" v-if="site_options?.GITHUB_OAUTH_ENABLED">Verify with previous GitHub connection</a>
<p v-else>Account verification is currently unavailable. Please try again later.</p>
</section>
<section v-if="site_options?.GITHUB_APP_ENABLED &amp;&amp; !accountRecovery">
<div class="connection-top"><i class="fab fa-github" aria-hidden="true"></i><span class="connection-badge">Read-only access</span></div>
<h2>Continue with GitHub</h2>
<p>Connect through the GitHub App with read-only repository access.</p>
@@ -17,12 +22,12 @@
<li><i class="fas fa-check" aria-hidden="true"></i> No permission to write to your code</li>
</ul>
<a class="btn btn-ink auth-continue" target="_self" href="/github/app/login">Sign in with GitHub <i class="fas fa-arrow-right" aria-hidden="true"></i></a>
<small>Select your repositories after signing in.</small>
<small>Your existing anonymizations and connections stay with your account.</small>
</section>
<section class="auth-legacy" v-if="site_options?.GITHUB_OAUTH_ENABLED">
<div class="connection-title"><h2>Legacy OAuth</h2><span class="connection-badge connection-badge-muted">For gists &amp; existing accounts</span></div>
<p>Use this connection for gists or your existing OAuth setup. GitHub's private repository scope includes write access.</p>
<a class="connection-link" target="_self" href="/github/login">Continue with OAuth <i class="fas fa-arrow-right" aria-hidden="true"></i></a>
<section class="auth-legacy" v-if="!site_options?.GITHUB_APP_ENABLED &amp;&amp; site_options?.GITHUB_OAUTH_ENABLED &amp;&amp; !accountRecovery">
<div class="connection-title"><h2>Continue with GitHub</h2></div>
<p>Sign in to access your account. GitHub OAuth includes write access to private repositories.</p>
<a class="connection-link" target="_self" href="/github/login">Sign in with GitHub <i class="fas fa-arrow-right" aria-hidden="true"></i></a>
</section>
<p role="status" v-if="site_options &amp;&amp; !site_options.GITHUB_APP_ENABLED &amp;&amp; !site_options.GITHUB_OAUTH_ENABLED">GitHub sign-in is currently unavailable. Please try again later.</p>
</div>
+28 -3
View File
@@ -284,7 +284,7 @@ export const homeController = function (state, http, location, window, timeout)
};
// Signed-out visitors cannot open the dashboard; send them to sign in.
state.featureHref = function (f) {
return f.needsUser && !state.user ? "/github/login" : f.href;
return f.needsUser && !state.user ? "/signin" : f.href;
};
state.featureTarget = function (f) {
return f.needsUser && !state.user ? "_self" : f.target || undefined;
@@ -890,10 +890,18 @@ export const anonymizeController = function (state, http, html, params, location
state.sourceUrl = "";
state.githubConnection = undefined;
state.githubConnections = null;
state.grantGitHubAccess = () => {
state.gistOAuthRequired = false;
const saveGitHubDraft = () => {
const draft = {};
for (const key of ["sourceUrl", "terms", "repoId", "pullRequestId", "gistId", "source", "options", "conference", "githubConnection"]) draft[key] = state[key];
sessionStorage.setItem("github-access-draft", JSON.stringify({ path: location.path(), savedAt: Date.now(), draft }));
};
state.connectGistOAuth = () => {
saveGitHubDraft();
window.location.href = "/github/login?returnTo=" + encodeURIComponent(location.path());
};
state.grantGitHubAccess = () => {
saveGitHubDraft();
const returnTo = location.path();
const repository = parseRepoFullName(state.sourceUrl) || "";
const route = state.githubConnections?.appConnected ? "/github/app/install" : "/github/app/login";
@@ -1110,7 +1118,7 @@ export const anonymizeController = function (state, http, html, params, location
state.options.expirationDate = new Date(res.data.options.expirationDate);
}
if (await restoreGitHubDraft()) return;
state.details = (await http.get(`/api/gist/source/${res.data.source.gistId}`)).data;
await getGistDetails();
},
() => { location.url("/404"); }
@@ -1142,6 +1150,7 @@ export const anonymizeController = function (state, http, html, params, location
state.readme = "";
state.html_readme = "";
state.detectedType = null;
state.gistOAuthRequired = false;
let o;
try {
@@ -1449,12 +1458,23 @@ export const anonymizeController = function (state, http, html, params, location
const o = parseGithubUrl(state.sourceUrl);
try {
resetValidity();
state.gistOAuthRequired = false;
if (state.githubConnections?.oauthConnected === false) {
state.gistOAuthRequired = true;
setValidity("sourceUrl", "missing", false);
return;
}
const res = await http.get(`/api/gist/source/${o.gistId}`);
state.details = res.data;
if (!state.gistId) {
state.gistId = "gist-" + o.gistId.substring(0, 6) + "-" + generateRandomId(4);
}
} catch (error) {
if (error.data?.error === "github_oauth_required") {
state.gistOAuthRequired = true;
setValidity("sourceUrl", "missing", false);
return;
}
if (error.data) {
translate("ERRORS." + error.data.error).then((translation) => {
state.addToast({ title: "Error", date: new Date(), body: translation });
@@ -2832,3 +2852,8 @@ export const connectionsController = function (state, http) {
};
state.loadConnections();
};
export const signinController = function (state, http) {
state.accountRecovery = false;
http.get("/github/account-recovery").then(res => { state.accountRecovery = res.data.required; }).catch(() => {});
};
+1 -1
View File
@@ -3,7 +3,7 @@ import * as admin from "./admin.js";
export const pageRoutes = [
{path: "/connections", template: "partials/connections.htm", title: "GitHub connections Anonymous GitHub", preserveExplorer: false, setup: (state, services) => pages.connectionsController(state, services.http)},
{path: "/signin", template: "partials/signin.htm", title: "Sign in Anonymous GitHub", preserveExplorer: false, setup: () => {}},
{path: "/signin", template: "partials/signin.htm", title: "Sign in Anonymous GitHub", preserveExplorer: false, setup: (state, services) => pages.signinController(state, services.http)},
{path: "/", template: "partials/home.htm", title: "Anonymous GitHub Share the code, not the author", preserveExplorer: false, setup: (state, services) => pages.homeController(state, services.http, services.location, services.window, services.timeout)},
{path: "/dashboard", template: "partials/dashboard.htm", title: "Your anonymizations Anonymous GitHub", preserveExplorer: false, setup: (state, services) => pages.unifiedDashboardController(state, services.http, services.location, services.promises, services.window, services.quotaService)},
{"path":"/pr-dashboard","redirect":"/dashboard"},
+21 -21
View File
File diff suppressed because one or more lines are too long
+15
View File
@@ -20,3 +20,18 @@ export function getLoginToken(
}
return null;
}
export function safeAuthReturnTo(value: unknown, fallback = "/dashboard"): string {
return typeof value === "string" && /^\/(?:(?:anonymize|pull-request-anonymize|gist-anonymize)(?:\/[\w-]+)?|connections|dashboard)(?:\?[^\\\r\n]*)?$/.test(value) ? value : fallback;
}
export type OAuthContext = { ownerId?: string; githubId?: string; returnTo: string; expires: number; recovery?: boolean };
declare module "express-session" {
interface SessionData {
githubRecovery?: OAuthContext;
githubOAuthFlow?: OAuthContext;
}
}
declare module "express-serve-static-core" {
interface Request { githubOAuthContext?: OAuthContext; }
}
+40 -3
View File
@@ -13,7 +13,7 @@ import AnonymousError from "../../core/AnonymousError";
import { setCredential } from "../../core/credentials";
import { hashToken } from "./token-auth";
import { createLogger, serializeError } from "../../core/logger";
import { getLoginToken, isDisabledAccount } from "./auth-utils";
import { getLoginToken, isDisabledAccount, safeAuthReturnTo } from "./auth-utils";
const logger = createLogger("auth");
@@ -28,7 +28,8 @@ export function ensureAuthenticated(
res.status(401).json({ error: "not_connected" });
}
const verify = async (
export const verify = async (
req: express.Request,
accessToken: string,
_refreshToken: string,
profile: Profile,
@@ -36,8 +37,19 @@ const verify = async (
): Promise<void> => {
let user: IUserDocument | null;
try {
const flow = req.githubOAuthContext;
const currentId = (req.user as { user?: { id?: string } } | undefined)?.user?.id;
if (!flow || flow.expires < Date.now() || (flow.githubId && flow.githubId !== profile.id)
|| (currentId && currentId !== flow.ownerId)) {
done(new AnonymousError("github_identity_mismatch", { httpStatus: 409 }));
return;
}
user = await UserModel.findOne({ "externalIDs.github": profile.id });
if (user) {
if (flow.ownerId && user.id !== flow.ownerId) {
done(new AnonymousError("github_identity_mismatch", { httpStatus: 409 }));
return;
}
if (isDisabledAccount(user.status)) {
done(
new AnonymousError(
@@ -53,6 +65,10 @@ const verify = async (
// existing account instead of creating a duplicate that would lose
// the isAdmin flag.
user = await UserModel.findOne({ username: profile.username });
if (flow.ownerId && (!user || user.id !== flow.ownerId)) {
done(new AnonymousError("github_identity_mismatch", { httpStatus: 409 }));
return;
}
if (user) {
if (user.externalIDs?.github && user.externalIDs.github !== profile.id) {
done(new AnonymousError("not_connected", { httpStatus: 401 }));
@@ -120,6 +136,7 @@ const verify = async (
if (config.GITHUB_OAUTH_ENABLED) passport.use(
new Strategy(
{
passReqToCallback: true,
clientID: config.CLIENT_ID,
clientSecret: config.CLIENT_SECRET,
callbackURL: config.AUTH_CALLBACK,
@@ -174,9 +191,22 @@ export function initSession() {
export const router = express.Router();
router.get("/account-recovery", (req, res) => {
res.set("Cache-Control", "no-store");
res.json({ required: !!req.session.githubRecovery && req.session.githubRecovery.expires > Date.now() });
});
router.get(
"/login",
(req, res, next) => config.GITHUB_OAUTH_ENABLED ? next() : res.status(503).json({ error: "github_oauth_disabled" }),
(req, res, next) => {
const recovery = req.query.recover === "1" ? req.session.githubRecovery : undefined;
if (req.query.recover === "1" && (!recovery || recovery.expires < Date.now())) return res.redirect("/signin");
const current = (req.user as { user?: IUserDocument } | undefined)?.user;
req.session.githubOAuthFlow = recovery || { ownerId: current?.id, githubId: current?.externalIDs?.github,
returnTo: safeAuthReturnTo(req.query.returnTo, current ? "/connections" : "/dashboard"), expires: Date.now() + 10 * 60000 };
next();
},
passport.authenticate("github", { scope: ["repo"] }), // Note the scope here
function (req: express.Request, res: express.Response) {
res.redirect("/");
@@ -187,6 +217,9 @@ router.get(
"/auth",
(req, res, next) => {
if (!config.GITHUB_OAUTH_ENABLED) return res.status(503).json({ error: "github_oauth_disabled" });
const flow = req.session.githubOAuthFlow;
delete req.session.githubOAuthFlow;
req.githubOAuthContext = flow;
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);
@@ -195,7 +228,11 @@ router.get(
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");
if (flow?.recovery) {
delete req.session.githubRecovery;
return res.redirect("/github/app/login?returnTo=" + encodeURIComponent(flow.returnTo));
}
res.redirect(flow?.returnTo || (existingId ? "/connections" : "/dashboard"));
});
})(req, res, next);
}
+11 -4
View File
@@ -11,14 +11,14 @@ 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";
import { isDisabledAccount, safeAuthReturnTo } 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";
return safeAuthReturnTo(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);
@@ -39,7 +39,7 @@ 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);
const flow = newFlow(ownerId, req.query.returnTo || (ownerId ? "/connections" : "/dashboard"));
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;
@@ -73,7 +73,14 @@ router.get("/app/callback", enabled, async (req, res) => {
}
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);
const existing = await UserModel.findOne({ username: profile.login });
if (existing) {
if (existing.externalIDs?.github || isDisabledAccount(existing.status)) throw appError("github_identity_mismatch", 409);
req.session.githubRecovery = { ownerId: existing.id, githubId: String(profile.id), returnTo: flow.returnTo,
expires: Date.now() + 10 * 60000, recovery: true };
await saveSession(req);
return res.redirect("/signin?recover=1");
}
user = new UserModel({ username: profile.login, externalIDs: { github: String(profile.id) }, photo: profile.avatar_url, emails: [] });
await user.save();
}
+1 -1
View File
@@ -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");
+1 -1
View File
@@ -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
View File
@@ -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" });
+1 -1
View File
@@ -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
View File
@@ -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([]);
});