mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-09-12 05:38:57 +02:00
Merge pull request #824 from tdurieux/codex/unified-github-signin
feat: unify GitHub sign-in and connect OAuth for gists
This commit is contained in:
@@ -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,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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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' && 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 && githubConnections?.appEnabled && githubConnections.appConnected && githubConnections.oauthConnected">
|
||||
<div class="repo-access-main">
|
||||
<div class="repo-access-controls">
|
||||
|
||||
@@ -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 && 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 && !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 & 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 && site_options?.GITHUB_OAUTH_ENABLED && !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 && !site_options.GITHUB_APP_ENABLED && !site_options.GITHUB_OAUTH_ENABLED">GitHub sign-in is currently unavailable. Please try again later.</p>
|
||||
</div>
|
||||
|
||||
+28
-3
@@ -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(() => {});
|
||||
};
|
||||
|
||||
@@ -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"},
|
||||
|
||||
Vendored
+21
-21
File diff suppressed because one or more lines are too long
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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,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();
|
||||
}
|
||||
|
||||
@@ -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