mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-09-18 08:32:17 +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
|
## User flow
|
||||||
|
|
||||||
Sign in offers App and OAuth choices. Both resolve the same existing account by
|
Sign in offers one GitHub App button and resolves existing accounts by GitHub
|
||||||
its GitHub numeric user ID. A signed-in user cannot attach a different GitHub
|
numeric user ID. Signing in preserves existing OAuth credentials and resource
|
||||||
identity. Legacy accounts without a verified GitHub ID require account recovery;
|
bindings. If the App is disabled, the button uses OAuth instead.
|
||||||
App login never automatically links by username or email.
|
|
||||||
|
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
|
On the anonymization form, **Connect read-only GitHub access** starts user
|
||||||
authorization and then repository installation. **Allow repository access on
|
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
|
App-connected accounts default to the App for new repository/PR access. The
|
||||||
explicit **Use existing OAuth access** choice handles repositories not yet
|
explicit **Use existing OAuth access** choice handles repositories not yet
|
||||||
available through the App. An App error never silently selects OAuth. Gists
|
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
|
**GitHub connections** lists each resource's current connection. First check
|
||||||
read-only access, then switch the resource. The switch validates the existing
|
read-only access, then switch the resource. The switch validates the existing
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"core.min.js": "core.c5bd53363a.min.js",
|
"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",
|
"mermaid.min.js": "mermaid.f848a72d16.min.js",
|
||||||
"all.min.css": "all.87fb103470.min.css",
|
"all.min.css": "all.87fb103470.min.css",
|
||||||
"markdown.min.js": "markdown.ad7b1d71c3.min.js",
|
"markdown.min.js": "markdown.ad7b1d71c3.min.js",
|
||||||
|
|||||||
@@ -130,7 +130,7 @@
|
|||||||
"invalid_webhook_signature": "The webhook signature is invalid.",
|
"invalid_webhook_signature": "The webhook signature is invalid.",
|
||||||
"webhook_processing_failed": "Webhook processing failed. Retry the delivery.",
|
"webhook_processing_failed": "Webhook processing failed. Retry the delivery.",
|
||||||
"github_app_authorization_cancelled": "GitHub authorization was cancelled.",
|
"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_app_refresh_busy": "GitHub credentials are being renewed. Please retry shortly.",
|
||||||
"github_grant_revocation_failed": "Unable to revoke the GitHub grant. Please retry.",
|
"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.",
|
"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);">
|
<small class="form-text" style="color: var(--ink-muted);">
|
||||||
Repository, pull request (…/pull/42) and gist (gist.github.com/…) URLs are all accepted.
|
Repository, pull request (…/pull/42) and gist (gist.github.com/…) URLs are all accepted.
|
||||||
</small>
|
</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">
|
<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-main">
|
||||||
<div class="repo-access-controls">
|
<div class="repo-access-controls">
|
||||||
|
|||||||
@@ -7,8 +7,13 @@
|
|||||||
<p class="paper-page-lede">Sign in to create and manage your anonymizations.</p>
|
<p class="paper-page-lede">Sign in to create and manage your anonymizations.</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="auth-options">
|
<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="accountRecovery">
|
||||||
<section v-if="site_options?.GITHUB_APP_ENABLED">
|
<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>
|
<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>
|
<h2>Continue with GitHub</h2>
|
||||||
<p>Connect through the GitHub App with read-only repository access.</p>
|
<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>
|
<li><i class="fas fa-check" aria-hidden="true"></i> No permission to write to your code</li>
|
||||||
</ul>
|
</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>
|
<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>
|
||||||
<section class="auth-legacy" v-if="site_options?.GITHUB_OAUTH_ENABLED">
|
<section class="auth-legacy" v-if="!site_options?.GITHUB_APP_ENABLED && site_options?.GITHUB_OAUTH_ENABLED && !accountRecovery">
|
||||||
<div class="connection-title"><h2>Legacy OAuth</h2><span class="connection-badge connection-badge-muted">For gists & existing accounts</span></div>
|
<div class="connection-title"><h2>Continue with GitHub</h2></div>
|
||||||
<p>Use this connection for gists or your existing OAuth setup. GitHub's private repository scope includes write access.</p>
|
<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">Continue with OAuth <i class="fas fa-arrow-right" aria-hidden="true"></i></a>
|
<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>
|
</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>
|
<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>
|
</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.
|
// Signed-out visitors cannot open the dashboard; send them to sign in.
|
||||||
state.featureHref = function (f) {
|
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) {
|
state.featureTarget = function (f) {
|
||||||
return f.needsUser && !state.user ? "_self" : f.target || undefined;
|
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.sourceUrl = "";
|
||||||
state.githubConnection = undefined;
|
state.githubConnection = undefined;
|
||||||
state.githubConnections = null;
|
state.githubConnections = null;
|
||||||
state.grantGitHubAccess = () => {
|
state.gistOAuthRequired = false;
|
||||||
|
const saveGitHubDraft = () => {
|
||||||
const draft = {};
|
const draft = {};
|
||||||
for (const key of ["sourceUrl", "terms", "repoId", "pullRequestId", "gistId", "source", "options", "conference", "githubConnection"]) draft[key] = state[key];
|
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 }));
|
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 returnTo = location.path();
|
||||||
const repository = parseRepoFullName(state.sourceUrl) || "";
|
const repository = parseRepoFullName(state.sourceUrl) || "";
|
||||||
const route = state.githubConnections?.appConnected ? "/github/app/install" : "/github/app/login";
|
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);
|
state.options.expirationDate = new Date(res.data.options.expirationDate);
|
||||||
}
|
}
|
||||||
if (await restoreGitHubDraft()) return;
|
if (await restoreGitHubDraft()) return;
|
||||||
state.details = (await http.get(`/api/gist/source/${res.data.source.gistId}`)).data;
|
await getGistDetails();
|
||||||
|
|
||||||
},
|
},
|
||||||
() => { location.url("/404"); }
|
() => { location.url("/404"); }
|
||||||
@@ -1142,6 +1150,7 @@ export const anonymizeController = function (state, http, html, params, location
|
|||||||
state.readme = "";
|
state.readme = "";
|
||||||
state.html_readme = "";
|
state.html_readme = "";
|
||||||
state.detectedType = null;
|
state.detectedType = null;
|
||||||
|
state.gistOAuthRequired = false;
|
||||||
|
|
||||||
let o;
|
let o;
|
||||||
try {
|
try {
|
||||||
@@ -1449,12 +1458,23 @@ export const anonymizeController = function (state, http, html, params, location
|
|||||||
const o = parseGithubUrl(state.sourceUrl);
|
const o = parseGithubUrl(state.sourceUrl);
|
||||||
try {
|
try {
|
||||||
resetValidity();
|
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}`);
|
const res = await http.get(`/api/gist/source/${o.gistId}`);
|
||||||
state.details = res.data;
|
state.details = res.data;
|
||||||
if (!state.gistId) {
|
if (!state.gistId) {
|
||||||
state.gistId = "gist-" + o.gistId.substring(0, 6) + "-" + generateRandomId(4);
|
state.gistId = "gist-" + o.gistId.substring(0, 6) + "-" + generateRandomId(4);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.data?.error === "github_oauth_required") {
|
||||||
|
state.gistOAuthRequired = true;
|
||||||
|
setValidity("sourceUrl", "missing", false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (error.data) {
|
if (error.data) {
|
||||||
translate("ERRORS." + error.data.error).then((translation) => {
|
translate("ERRORS." + error.data.error).then((translation) => {
|
||||||
state.addToast({ title: "Error", date: new Date(), body: translation });
|
state.addToast({ title: "Error", date: new Date(), body: translation });
|
||||||
@@ -2832,3 +2852,8 @@ export const connectionsController = function (state, http) {
|
|||||||
};
|
};
|
||||||
state.loadConnections();
|
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 = [
|
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: "/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: "/", 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: "/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"},
|
{"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;
|
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 { setCredential } from "../../core/credentials";
|
||||||
import { hashToken } from "./token-auth";
|
import { hashToken } from "./token-auth";
|
||||||
import { createLogger, serializeError } from "../../core/logger";
|
import { createLogger, serializeError } from "../../core/logger";
|
||||||
import { getLoginToken, isDisabledAccount } from "./auth-utils";
|
import { getLoginToken, isDisabledAccount, safeAuthReturnTo } from "./auth-utils";
|
||||||
|
|
||||||
const logger = createLogger("auth");
|
const logger = createLogger("auth");
|
||||||
|
|
||||||
@@ -28,7 +28,8 @@ export function ensureAuthenticated(
|
|||||||
res.status(401).json({ error: "not_connected" });
|
res.status(401).json({ error: "not_connected" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const verify = async (
|
export const verify = async (
|
||||||
|
req: express.Request,
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
_refreshToken: string,
|
_refreshToken: string,
|
||||||
profile: Profile,
|
profile: Profile,
|
||||||
@@ -36,8 +37,19 @@ const verify = async (
|
|||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
let user: IUserDocument | null;
|
let user: IUserDocument | null;
|
||||||
try {
|
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 });
|
user = await UserModel.findOne({ "externalIDs.github": profile.id });
|
||||||
if (user) {
|
if (user) {
|
||||||
|
if (flow.ownerId && user.id !== flow.ownerId) {
|
||||||
|
done(new AnonymousError("github_identity_mismatch", { httpStatus: 409 }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isDisabledAccount(user.status)) {
|
if (isDisabledAccount(user.status)) {
|
||||||
done(
|
done(
|
||||||
new AnonymousError(
|
new AnonymousError(
|
||||||
@@ -53,6 +65,10 @@ const verify = async (
|
|||||||
// existing account instead of creating a duplicate that would lose
|
// existing account instead of creating a duplicate that would lose
|
||||||
// the isAdmin flag.
|
// the isAdmin flag.
|
||||||
user = await UserModel.findOne({ username: profile.username });
|
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) {
|
||||||
if (user.externalIDs?.github && user.externalIDs.github !== profile.id) {
|
if (user.externalIDs?.github && user.externalIDs.github !== profile.id) {
|
||||||
done(new AnonymousError("not_connected", { httpStatus: 401 }));
|
done(new AnonymousError("not_connected", { httpStatus: 401 }));
|
||||||
@@ -120,6 +136,7 @@ const verify = async (
|
|||||||
if (config.GITHUB_OAUTH_ENABLED) passport.use(
|
if (config.GITHUB_OAUTH_ENABLED) passport.use(
|
||||||
new Strategy(
|
new Strategy(
|
||||||
{
|
{
|
||||||
|
passReqToCallback: true,
|
||||||
clientID: config.CLIENT_ID,
|
clientID: config.CLIENT_ID,
|
||||||
clientSecret: config.CLIENT_SECRET,
|
clientSecret: config.CLIENT_SECRET,
|
||||||
callbackURL: config.AUTH_CALLBACK,
|
callbackURL: config.AUTH_CALLBACK,
|
||||||
@@ -174,9 +191,22 @@ export function initSession() {
|
|||||||
|
|
||||||
export const router = express.Router();
|
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(
|
router.get(
|
||||||
"/login",
|
"/login",
|
||||||
(req, res, next) => config.GITHUB_OAUTH_ENABLED ? next() : res.status(503).json({ error: "github_oauth_disabled" }),
|
(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
|
passport.authenticate("github", { scope: ["repo"] }), // Note the scope here
|
||||||
function (req: express.Request, res: express.Response) {
|
function (req: express.Request, res: express.Response) {
|
||||||
res.redirect("/");
|
res.redirect("/");
|
||||||
@@ -187,6 +217,9 @@ router.get(
|
|||||||
"/auth",
|
"/auth",
|
||||||
(req, res, next) => {
|
(req, res, next) => {
|
||||||
if (!config.GITHUB_OAUTH_ENABLED) return res.status(503).json({ error: "github_oauth_disabled" });
|
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;
|
const existingId = (req.user as { user?: { id?: string } } | undefined)?.user?.id;
|
||||||
passport.authenticate("github", (error: Error | null, identity: Express.User | false) => {
|
passport.authenticate("github", (error: Error | null, identity: Express.User | false) => {
|
||||||
if (error) return next(error);
|
if (error) return next(error);
|
||||||
@@ -195,7 +228,11 @@ router.get(
|
|||||||
if (existingId && id !== existingId) return res.status(409).json({ error: "github_identity_mismatch" });
|
if (existingId && id !== existingId) return res.status(409).json({ error: "github_identity_mismatch" });
|
||||||
req.login(identity, loginError => {
|
req.login(identity, loginError => {
|
||||||
if (loginError) return next(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);
|
})(req, res, next);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ import { getCredentialToken } from "../../core/credentials";
|
|||||||
import { APP_PROVIDER, appError, appUserToken, AppInstallation, clearAppTokenCache, exchangeAppToken,
|
import { APP_PROVIDER, appError, appUserToken, AppInstallation, clearAppTokenCache, exchangeAppToken,
|
||||||
githubRequest, GitHubRepositoryInfo, reconcileAppGrant, reconcileInstallation, installationURL, saveAppGrant, selectRepositoryAccess, userInstallations } from "../../core/github-app";
|
githubRequest, GitHubRepositoryInfo, reconcileAppGrant, reconcileInstallation, installationURL, saveAppGrant, selectRepositoryAccess, userInstallations } from "../../core/github-app";
|
||||||
import { getUser, handleError } from "./route-utils";
|
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 };
|
type Flow = { state: string; expires: number; ownerId?: string; returnTo: string; repository?: string; install?: boolean };
|
||||||
declare module "express-session" {
|
declare module "express-session" {
|
||||||
interface SessionData { githubAppFlow?: Flow; githubInstallFlow?: Flow; githubConnectionCSRF?: string; }
|
interface SessionData { githubAppFlow?: Flow; githubInstallFlow?: Flow; githubConnectionCSRF?: string; }
|
||||||
}
|
}
|
||||||
export function safeReturnTo(value: unknown): 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 {
|
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);
|
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) => {
|
router.get("/app/login", enabled, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const ownerId = req.isAuthenticated() ? (await getUser(req)).id : undefined;
|
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";
|
flow.install = req.query.install === "1";
|
||||||
if (typeof req.query.repository === "string" && /^[\w.-]+\/[\w.-]+$/.test(req.query.repository)) flow.repository = req.query.repository;
|
if (typeof req.query.repository === "string" && /^[\w.-]+\/[\w.-]+$/.test(req.query.repository)) flow.repository = req.query.repository;
|
||||||
req.session.githubAppFlow = flow;
|
req.session.githubAppFlow = flow;
|
||||||
@@ -73,7 +73,14 @@ router.get("/app/callback", enabled, async (req, res) => {
|
|||||||
}
|
}
|
||||||
if (!user) {
|
if (!user) {
|
||||||
// A matching login name alone is not proof of account ownership.
|
// 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: [] });
|
user = new UserModel({ username: profile.login, externalIDs: { github: String(profile.id) }, photo: profile.avatar_url, emails: [] });
|
||||||
await user.save();
|
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 () => {
|
it("OAuth login writes a credential and returns a token-free session user", async () => {
|
||||||
const passport = require("passport");
|
const passport = require("passport");
|
||||||
require("../src/server/routes/connection");
|
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: [],
|
id: "external-test", username: owner.username, emails: [], photos: [],
|
||||||
}, (error, user) => error ? reject(error) : resolve(user)));
|
}, (error, user) => error ? reject(error) : resolve(user)));
|
||||||
expect(JSON.stringify(result)).not.to.include("oauth-secret");
|
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 () {
|
it("sends signed-out visitors to sign in instead of the dashboard", function () {
|
||||||
const manage = f => f.key === "manage";
|
const manage = f => f.key === "manage";
|
||||||
const out = landing(null);
|
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");
|
expect(out.scope.featureTarget(out.scope.features.find(manage))).to.equal("_self");
|
||||||
const signedIn = landing({ username: "jane" });
|
const signedIn = landing({ username: "jane" });
|
||||||
expect(signedIn.scope.featureHref(signedIn.scope.features.find(manage))).to.equal("/dashboard");
|
expect(signedIn.scope.featureHref(signedIn.scope.features.find(manage))).to.equal("/dashboard");
|
||||||
|
|||||||
+63
-3
@@ -1,4 +1,5 @@
|
|||||||
const { expect } = require("chai");
|
const { expect } = require("chai");
|
||||||
|
const { URL } = require("node:url");
|
||||||
const { createHmac, generateKeyPairSync, createVerify } = require("crypto");
|
const { createHmac, generateKeyPairSync, createVerify } = require("crypto");
|
||||||
const process = require("process");
|
const process = require("process");
|
||||||
const { setTimeout } = require("timers");
|
const { setTimeout } = require("timers");
|
||||||
@@ -96,7 +97,7 @@ describe("GitHub App protocol boundaries", () => {
|
|||||||
const describeMongo = process.env.TEST_MONGODB_URI ? describe : describe.skip;
|
const describeMongo = process.env.TEST_MONGODB_URI ? describe : describe.skip;
|
||||||
describeMongo("GitHub App credential and repository integration", function () {
|
describeMongo("GitHub App credential and repository integration", function () {
|
||||||
this.timeout(15000);
|
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,
|
const data = (suffix = "1", expires = 3600) => ({ access_token: "ghu_access" + suffix, refresh_token: "ghr_refresh" + suffix,
|
||||||
expires_in: expires, refresh_token_expires_in: 100000 });
|
expires_in: expires, refresh_token_expires_in: 100000 });
|
||||||
before(async () => {
|
before(async () => {
|
||||||
@@ -111,13 +112,15 @@ describeMongo("GitHub App credential and repository integration", function () {
|
|||||||
api.use(express.json());
|
api.use(express.json());
|
||||||
api.use((req, _res, next) => {
|
api.use((req, _res, next) => {
|
||||||
req.session = session;
|
req.session = session;
|
||||||
req.user = { user: owner };
|
req.session.regenerate = done => done();
|
||||||
req.isAuthenticated = () => true;
|
req.user = authenticated ? { user: owner } : undefined;
|
||||||
|
req.isAuthenticated = () => authenticated;
|
||||||
req.login = (identity, done) => { req.user = identity; done(); };
|
req.login = (identity, done) => { req.user = identity; done(); };
|
||||||
req.logout = done => done();
|
req.logout = done => done();
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
api.use("/github", githubAppRouter);
|
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)); });
|
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}`;
|
base = `http://127.0.0.1:${server.address().port}`;
|
||||||
});
|
});
|
||||||
@@ -134,6 +137,7 @@ describeMongo("GitHub App credential and repository integration", function () {
|
|||||||
calls = [];
|
calls = [];
|
||||||
await Credentials.deleteMany({}); await Users.deleteMany({}); await Installations.deleteMany({});
|
await Credentials.deleteMany({}); await Users.deleteMany({}); await Installations.deleteMany({});
|
||||||
app.clearAppTokenCache();
|
app.clearAppTokenCache();
|
||||||
|
authenticated = true;
|
||||||
owner = await Users.create({ username: "owner", externalIDs: { github: "10" } });
|
owner = await Users.create({ username: "owner", externalIDs: { github: "10" } });
|
||||||
});
|
});
|
||||||
afterEach(() => { globalThis.fetch = previousFetch; });
|
afterEach(() => { globalThis.fetch = previousFetch; });
|
||||||
@@ -249,6 +253,62 @@ describeMongo("GitHub App credential and repository integration", function () {
|
|||||||
expect(replay.status).to.equal(400);
|
expect(replay.status).to.equal(400);
|
||||||
expect(calls).to.have.length(2);
|
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 () => {
|
it("rejects linking a different GitHub identity", async () => {
|
||||||
session.githubAppFlow = { state: "state", ownerId: owner.id, expires: Date.now() + 60000, returnTo: "/connections" };
|
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" });
|
mock(url => url.includes("/login/oauth/access_token") ? data() : { id: 99, login: "other" });
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ describe("production regressions", function () {
|
|||||||
let calls = 0;
|
let calls = 0;
|
||||||
stub(UserModel, "findOne", async () => ++calls === 1 ? null : { externalIDs: { github: "old-id" }, isAdmin: true });
|
stub(UserModel, "findOne", async () => ++calls === 1 ? null : { externalIDs: { github: "old-id" }, isAdmin: true });
|
||||||
stub(UserModel, "updateOne", () => { throw new Error("must not overwrite identity"); });
|
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");
|
expect(error.message).to.equal("not_connected");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+35
-1
@@ -89,10 +89,44 @@ describe("Vue 3 UI", function () {
|
|||||||
expect(ui.errors).to.deep.equal([]);
|
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 } });
|
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/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/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([]);
|
expect(ui.errors).to.deep.equal([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user