diff --git a/e2e/selfhost/mcp-oauth-reconnect-health.test.ts b/e2e/selfhost/mcp-oauth-reconnect-health.test.ts new file mode 100644 index 000000000..b363c38dd --- /dev/null +++ b/e2e/selfhost/mcp-oauth-reconnect-health.test.ts @@ -0,0 +1,278 @@ +// Selfhost repros for two MCP OAuth bugs seen with a DCR connection whose +// refresh token is rejected by the provider as `invalid_grant`. +import { randomBytes } from "node:crypto"; + +import { Effect } from "effect"; +import { expect } from "@effect/vitest"; +import type { HttpApiClient } from "effect/unstable/httpapi"; +import type { Page } from "playwright"; +import { composePluginApi } from "@executor-js/api/server"; +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, +} from "@executor-js/sdk/shared"; +import { serveOAuthTestServer, type OAuthTestServerShape } from "@executor-js/sdk/testing"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; + +const api = composePluginApi([mcpHttpPlugin()] as const); +type Client = HttpApiClient.ForApi; + +const name = ConnectionName.make("main"); +const template = AuthTemplateSlug.make("oauth2"); + +const freshSlug = (prefix: string): string => `${prefix}-${randomBytes(4).toString("hex")}`; + +const healthPath = (slug: IntegrationSlug): string => + `/api/connections/org/${String(slug)}/${String(name)}/health`; + +const oauthReconnectRequest = (url: string): boolean => + url.includes("/api/oauth/probe") || + url.includes("/api/oauth/start") || + url.includes("/api/oauth/clients/register-dynamic"); + +const connectionsSection = (page: Page) => + page.locator("section").filter({ + has: page.getByRole("heading", { level: 3, name: "Connections" }), + }); + +const requiredRedirect = (response: Response, from: string): string => { + const location = response.headers.get("location"); + if (!location) { + throw new Error(`Expected redirect from ${from}, got HTTP ${response.status}`); + } + return new URL(location, from).toString(); +}; + +const completeAuthorization = (authorizationUrl: string) => + Effect.promise(async () => { + const login = await fetch(authorizationUrl, { redirect: "manual" }); + const loginUrl = requiredRedirect(login, authorizationUrl); + const credentials = Buffer.from("alice:password").toString("base64"); + const callback = await fetch(loginUrl, { + method: "POST", + headers: { authorization: `Basic ${credentials}` }, + redirect: "manual", + }); + const callbackUrl = requiredRedirect(callback, loginUrl); + const parsed = new URL(callbackUrl); + const code = parsed.searchParams.get("code"); + if (!code) throw new Error(`OAuth callback did not include a code: ${callbackUrl}`); + return { code }; + }); + +const seedExpiredDcrMcpOAuthConnection = (client: Client, prefix: string) => + Effect.gen(function* () { + const oauth = yield* serveOAuthTestServer({ + scopes: ["channels:history", "users:read"], + supportRefresh: false, + tokenExpiresInSeconds: 0, + invalidRefreshTokenDescription: "Grant not found", + }); + const slug = IntegrationSlug.make(freshSlug(prefix)); + const clientSlug = OAuthClientSlug.make(freshSlug(`${prefix}-client`)); + + yield* client.mcp.addServer({ + payload: { + transport: "remote", + name: `OAuth repro ${String(slug)}`, + endpoint: oauth.mcpResourceUrl, + slug: String(slug), + authenticationTemplate: [{ kind: "oauth2" }], + }, + }); + yield* Effect.addFinalizer(() => + client.mcp.removeServer({ params: { slug } }).pipe(Effect.ignore), + ); + + const probe = yield* client.oauth.probe({ payload: { url: oauth.mcpResourceUrl } }); + if (!probe.registrationEndpoint) { + return yield* Effect.die("OAuth probe did not discover a DCR registration endpoint"); + } + + const registered = yield* client.oauth.registerDynamic({ + payload: { + owner: "org", + slug: clientSlug, + issuer: probe.issuer ?? null, + registrationEndpoint: probe.registrationEndpoint, + authorizationUrl: probe.authorizationUrl, + tokenUrl: probe.tokenUrl, + resource: probe.resource ?? oauth.mcpResourceUrl, + scopes: probe.scopesSupported ?? [], + tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, + clientName: "Executor e2e MCP OAuth repro", + originIntegration: slug, + }, + }); + yield* Effect.addFinalizer(() => + client.oauth + .removeClient({ params: { slug: registered.client }, payload: { owner: "org" } }) + .pipe(Effect.ignore), + ); + + const started = yield* client.oauth.start({ + payload: { + owner: "org", + client: registered.client, + clientOwner: "org", + name, + integration: slug, + template, + }, + }); + expect(started.status, "DCR MCP OAuth starts an authorization-code redirect").toBe("redirect"); + if (started.status !== "redirect") return yield* Effect.die("OAuth start did not redirect"); + + const callback = yield* completeAuthorization(started.authorizationUrl); + yield* client.oauth.complete({ payload: { state: started.state, code: callback.code } }); + yield* Effect.addFinalizer(() => + client.connections + .remove({ params: { owner: "org", integration: slug, name } }) + .pipe(Effect.ignore), + ); + yield* oauth.clearRequests; + + return { oauth, slug }; + }); + +const logTokenRequests = (label: string, oauth: OAuthTestServerShape) => + Effect.gen(function* () { + const requests = yield* oauth.requests; + const refresh = requests + .filter((request) => request.path === "/token" && request.body.includes("refresh_token")) + .map((request) => `${request.method} ${request.path} ${request.body}`); + console.info(`[BUG repro] ${label}: refresh token requests: ${refresh.join(" | ") || "none"}`); + }); + +scenario( + "MCP OAuth · invalid_grant refresh during health check returns expired instead of 500", + { + timeout: 180_000, + }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeApiClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeApiClient(api, identity); + const { oauth, slug } = yield* seedExpiredDcrMcpOAuthConnection(client, "mcp-hc-invalid"); + + const apiResult = yield* client.connections.checkHealth({ + params: { owner: "org", integration: slug, name }, + query: {}, + }); + expect(apiResult.status, "typed checkHealth classifies the dead grant").toBe("expired"); + expect(apiResult.detail, "the provider rejection detail is surfaced").toContain( + "Grant not found", + ); + const reread = yield* client.connections.get({ + params: { owner: "org", integration: slug, name }, + }); + expect(reread.lastHealth?.status, "the expired health verdict persisted").toBe("expired"); + expect(reread.lastHealth?.detail, "the persisted detail is useful").toContain( + "Grant not found", + ); + yield* logTokenRequests("typed checkHealth", oauth); + yield* oauth.clearRequests; + + yield* browser.session(identity, async ({ page, step }) => { + const connections = connectionsSection(page); + const menuTrigger = connections.locator('button[aria-haspopup="menu"]').first(); + + await step("Open the MCP integration with its expired OAuth connection", async () => { + await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); + await connections.getByText("main", { exact: true }).waitFor({ timeout: 30_000 }); + }); + + await step( + "Check now should render Expired without the generic failure toast", + async () => { + const responsePromise = page.waitForResponse( + (response) => + response.url().includes(healthPath(slug)) && response.request().method() === "POST", + { timeout: 30_000 }, + ); + await menuTrigger.click(); + await page.getByRole("menuitem", { name: "Check now" }).click(); + const response = await responsePromise; + const body = await response.text(); + console.info(`[BUG repro] UI health response: ${response.status()} ${body}`); + + expect( + response.status(), + `health check should return HTTP 200 with status expired; body: ${body}`, + ).toBe(200); + const json = JSON.parse(body) as { readonly status?: string }; + expect(json.status, "unrefreshable OAuth grants are an expired credential").toBe( + "expired", + ); + await connections.getByLabel("Status: Expired").waitFor({ timeout: 30_000 }); + await page.getByText("Health check failed", { exact: true }).waitFor({ + state: "hidden", + timeout: 5_000, + }); + }, + ); + }); + }), + ), +); + +scenario( + "MCP OAuth · DCR reconnect keeps the dialog open and reaches OAuth start", + { + timeout: 180_000, + }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeApiClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeApiClient(api, identity); + const { slug } = yield* seedExpiredDcrMcpOAuthConnection(client, "mcp-dcr-reconnect"); + + yield* browser.session(identity, async ({ page, step }) => { + const connections = connectionsSection(page); + const menuTrigger = connections.locator('button[aria-haspopup="menu"]').first(); + const dialog = page.getByRole("dialog"); + const oauthRequests: string[] = []; + + page.on("request", (request) => { + if (oauthReconnectRequest(request.url())) oauthRequests.push(request.url()); + }); + + await step("Open the MCP integration with its DCR OAuth connection", async () => { + await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); + await connections.getByText("main", { exact: true }).waitFor({ timeout: 30_000 }); + }); + + await step("Reconnect should keep a dialog visible and reach OAuth", async () => { + const oauthRequest = page + .waitForRequest((request) => oauthReconnectRequest(request.url()), { timeout: 30_000 }) + .then((request) => request.url()); + + await menuTrigger.click(); + await page.getByRole("menuitem", { name: "Reconnect" }).click(); + + await dialog.waitFor({ state: "visible", timeout: 30_000 }); + const reachedOAuth = await oauthRequest; + await page.waitForTimeout(2_000); + await dialog.waitFor({ state: "visible", timeout: 1_000 }); + console.info( + `[MCP OAuth repro] reconnect dialog stayed open; OAuth requests: ${ + oauthRequests.join(", ") || reachedOAuth + }`, + ); + expect(reachedOAuth, "Reconnect should issue an OAuth request").toBeTruthy(); + }); + }); + }), + ), +); diff --git a/e2e/src/scenario.ts b/e2e/src/scenario.ts index 81e8d314b..db4fad86d 100644 --- a/e2e/src/scenario.ts +++ b/e2e/src/scenario.ts @@ -58,6 +58,10 @@ export interface ScenarioOptions { * body never runs. Use ONLY for a scenario blocked on a tracked, out-of-scope * issue; state the reason here so the skip is self-documenting in the source. */ readonly skip?: string; + /** When set, the scenario is registered as a Vitest expected failure. The body + * still runs and records artifacts; Vitest keeps CI green only while the + * tracked bug still reproduces. */ + readonly expectedFailure?: string; } type AllServices = @@ -129,8 +133,9 @@ export const scenario = ( const dir = join(RUNS_DIR, target.name, slugify(name)); const context = contextFor(target, dir); const testFile = captureTestFile(); + const register = options.expectedFailure ? it.live.fails : it.live; - it.live( + register( name, (testCtx) => Effect.gen(function* () { diff --git a/packages/core/sdk/src/client.ts b/packages/core/sdk/src/client.ts index 4c426c0b9..5377b6553 100644 --- a/packages/core/sdk/src/client.ts +++ b/packages/core/sdk/src/client.ts @@ -122,13 +122,19 @@ export interface IntegrationAccountHandoff { readonly template?: string; /** Non-secret connection label to prefill. */ readonly label?: string; + /** Existing display identity to preserve when reconnecting a saved row. */ + readonly identityLabel?: string; /** Present when the agent handed off a CONFIDENTIAL OAuth-app registration - * (via `oauth.clients.createHandoff`): the accounts UI opens the + * (via `oauth.clients.createHandoff`) or when a saved OAuth connection is + * reconnecting through its stored app. Registration opens the * Register-OAuth-app form pre-filled with these NON-secret fields, and the - * human types the client secret directly into the browser. */ + * human types the client secret directly into the browser. Reconnect starts + * OAuth with the existing public client. */ readonly oauthClient?: { + readonly action?: "register" | "reconnect"; /** Preselected client slug; when set the form's slug is fixed. */ readonly slug?: string; + readonly owner?: "org" | "user"; readonly grant?: string; readonly clientId?: string; readonly authorizationUrl?: string; diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 9b9444db4..071d4a21b 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -2594,6 +2594,40 @@ export const createExecutor = ({ status: "unknown", checkedAt: Date.now() }); + const persistHealthResult = ( + ref: ConnectionRef, + result: HealthCheckResult, + ): Effect.Effect => + core + .updateMany("connection", { + where: (b: AnyCb) => + b.and( + b("owner", "=", String(ref.owner)), + b("integration", "=", String(ref.integration)), + b("name", "=", String(ref.name)), + ), + set: { last_health: result, updated_at: new Date() }, + }) + .pipe(Effect.ignore); + + const healthFromCredentialResolutionError = ( + err: CredentialResolutionError, + ): Effect.Effect => + err.reauthRequired === true + ? Effect.succeed({ + status: "expired", + checkedAt: Date.now(), + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: CredentialResolutionError carries a typed `message` field + detail: err.message, + }) + : Effect.fail( + new StorageError({ + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: CredentialResolutionError carries a typed `message` field + message: err.message, + cause: err, + }), + ); + // Resolve an in-flight credential's value map (key-first validation) without // saving anything. Mirrors `resolveConnectionValues` for the saved-row path: // pasted `value`/`values` are used directly; `from` origins resolve through @@ -2654,41 +2688,33 @@ export const createExecutor = - b.and( - b("owner", "=", String(ref.owner)), - b("integration", "=", String(ref.integration)), - b("name", "=", String(ref.name)), - ), - set: { last_health: result, updated_at: new Date() }, - }) - .pipe(Effect.ignore); + yield* persistHealthResult(ref, result); return result; }); diff --git a/packages/core/sdk/src/oauth-flow.test.ts b/packages/core/sdk/src/oauth-flow.test.ts index 901db7b46..971554dc9 100644 --- a/packages/core/sdk/src/oauth-flow.test.ts +++ b/packages/core/sdk/src/oauth-flow.test.ts @@ -45,6 +45,11 @@ const oauthPlugin = definePlugin(() => ({ }, // Echo the resolved credential value (the OAuth access token) back out. invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }), + checkHealth: ({ credential }) => + Effect.succeed({ + status: credential.value === null ? "expired" : "healthy", + checkedAt: Date.now(), + }), extension: (ctx) => ({ seed: (scopes: readonly string[] = []) => ctx.core.integrations.register({ @@ -621,6 +626,82 @@ describe("oauth token refresh in resolveConnectionValue", () => { }), ), ); + + it.effect("checkHealth records expired when OAuth refresh is rejected", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ + scopes: ["read"], + supportRefresh: false, + tokenExpiresInSeconds: 0, + invalidRefreshTokenDescription: "Grant not found", + }); + const harness = yield* makeTestWorkspaceHarness({ plugins }); + const { executor, config } = harness; + yield* executor.acme.seed(); + + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + resource: server.mcpResourceUrl, + }); + + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + yield* executor.oauth.complete({ + state: started.state, + code: callback.code, + }); + + yield* Effect.promise(() => + config.db.updateMany("connection", { + where: (b) => b("name", "=", "main"), + set: { expires_at: Date.now() - 60_000 }, + }), + ); + yield* server.clearRequests; + + const result = yield* executor.connections.checkHealth({ + owner: "org", + integration: INTEG, + name: ConnectionName.make("main"), + }); + + expect(result.status).toBe("expired"); + expect(result.detail).toContain("Grant not found"); + const refreshed = yield* executor.connections.get({ + owner: "org", + integration: INTEG, + name: ConnectionName.make("main"), + }); + expect(refreshed?.lastHealth).toMatchObject({ + status: "expired", + detail: expect.stringContaining("Grant not found"), + }); + const requests = yield* server.requests; + const refreshRequest = requests.find( + (r) => r.path === "/token" && r.method === "POST" && r.body.includes("refresh_token"), + ); + expect(refreshRequest).toBeDefined(); + }), + ), + ); }); // Multi-site providers (Datadog) statically advertise one region's token diff --git a/packages/core/sdk/src/testing/oauth-test-server.ts b/packages/core/sdk/src/testing/oauth-test-server.ts index 5c0bbd75f..e1d993be1 100644 --- a/packages/core/sdk/src/testing/oauth-test-server.ts +++ b/packages/core/sdk/src/testing/oauth-test-server.ts @@ -53,6 +53,8 @@ export interface OAuthTestServerOptions { readonly scopes?: readonly string[]; readonly omitTokenResponseScopes?: readonly string[]; readonly supportRefresh?: boolean; + readonly tokenExpiresInSeconds?: number; + readonly invalidRefreshTokenDescription?: string; /** Gate Dynamic Client Registration on the requested redirect URIs. When set, * `/register` returns `400 invalid_redirect_uri` unless every requested * `redirect_uris` entry is approved. Mirrors authorization servers (e.g. @@ -412,6 +414,9 @@ export const serveOAuthTestServer = ( ...(options.users ?? {}), }; const supportRefresh = options.supportRefresh ?? true; + const tokenExpiresInSeconds = options.tokenExpiresInSeconds ?? 3600; + const invalidRefreshTokenDescription = + options.invalidRefreshTokenDescription ?? "Unknown refresh token"; const scopes = options.scopes ?? defaultScopes; const omittedTokenResponseScopes = new Set(options.omitTokenResponseScopes ?? []); const tokenResponseScope = (scope: string | null): string | undefined => { @@ -645,7 +650,7 @@ export const serveOAuthTestServer = ( access_token: accessToken, refresh_token: refreshToken, token_type: "Bearer", - expires_in: 3600, + expires_in: tokenExpiresInSeconds, ...(scope ? { scope } : {}), }, { "cache-control": "no-store" }, @@ -656,7 +661,7 @@ export const serveOAuthTestServer = ( const refreshToken = params.get("refresh_token"); const record = refreshToken ? refreshTokens.get(refreshToken) : undefined; if (!supportRefresh || !refreshToken || !record || record.clientId !== clientId) { - return oauthError(400, "invalid_grant", "Unknown refresh token"); + return oauthError(400, "invalid_grant", invalidRefreshTokenDescription); } const nextAccessToken = `at_${randomUUID()}`; const nextRefreshToken = `rt_${randomUUID()}`; @@ -673,7 +678,7 @@ export const serveOAuthTestServer = ( access_token: nextAccessToken, refresh_token: nextRefreshToken, token_type: "Bearer", - expires_in: 3600, + expires_in: tokenExpiresInSeconds, ...(scope ? { scope } : {}), }, { "cache-control": "no-store" }, @@ -688,7 +693,7 @@ export const serveOAuthTestServer = ( { access_token: accessToken, token_type: "Bearer", - expires_in: 3600, + expires_in: tokenExpiresInSeconds, scope: params.get("scope") ?? scopes.join(" "), }, { "cache-control": "no-store" }, diff --git a/packages/react/src/components/accounts-section.tsx b/packages/react/src/components/accounts-section.tsx index 1b6187a98..d1e7ca38e 100644 --- a/packages/react/src/components/accounts-section.tsx +++ b/packages/react/src/components/accounts-section.tsx @@ -501,6 +501,7 @@ export function AccountsSection(props: { methods={methods} onEdit={setEditingConnection} onDcrReconnect={(connection: Connection) => { + if (connection.oauthClient == null) return; setReconnectHandoff({ key: `reconnect:${connection.owner}:${String(connection.integration)}:${String( connection.name, @@ -508,6 +509,14 @@ export function AccountsSection(props: { owner: connection.owner, template: String(connection.template), label: String(connection.name), + ...(connection.identityLabel != null + ? { identityLabel: connection.identityLabel } + : {}), + oauthClient: { + action: "reconnect", + slug: String(connection.oauthClient), + owner: connection.oauthClientOwner ?? connection.owner, + }, }); }} declaredScopes={declaredScopes} diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index 50d22bc2f..7259b199c 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import * as Exit from "effect/Exit"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; @@ -1310,8 +1310,10 @@ function AddAccountModalView(props: AddAccountModalProps) { // user can cancel back out without it springing open again; retries on later // renders only while the methods list is still empty. const oauthHandoffOpenedKey = useRef(null); + const oauthReconnectOpenedKey = useRef(null); useEffect(() => { if (!initialState?.oauthClient) return; + if (initialState.oauthClient.action === "reconnect") return; if (oauthHandoffOpenedKey.current === initialState.key) return; const oauthMethod = allMethods.find((m: AuthMethod) => m.kind === "oauth"); if (!oauthMethod) return; @@ -1603,7 +1605,58 @@ function AddAccountModalView(props: AddAccountModalProps) { // Just ask the parent to close. Reopening remounts this whole component (see // AddAccountModal), so there is nothing to hand-reset: the form fields and the // OAuth popup flow's busy state die with this instance. - const close = () => onOpenChange(false); + const close = useCallback(() => onOpenChange(false), [onOpenChange]); + + useEffect(() => { + const handoff = initialState; + const oauthClient = handoff?.oauthClient; + if (!handoff || oauthClient?.action !== "reconnect") return; + if (oauthReconnectOpenedKey.current === handoff.key) return; + const client = oauthClient.slug; + const clientOwner = oauthClient.owner ?? handoff.owner; + const connectionOwner = handoff.owner; + const connectionName = handoff.label; + const oauthMethod = handoff.template + ? allMethods.find( + (m: AuthMethod) => + m.kind === "oauth" && + (m.id === handoff.template || String(m.template) === handoff.template), + ) + : allMethods.find((m: AuthMethod) => m.kind === "oauth"); + if (!client || !clientOwner || !connectionOwner || !connectionName || !oauthMethod) return; + + oauthReconnectOpenedKey.current = handoff.key; + setMethodId(oauthMethod.id); + void oauthPopup.start({ + payload: { + client: OAuthClientSlug.make(client), + clientOwner, + owner: connectionOwner, + name: ConnectionName.make(connectionName), + integration, + template: oauthMethod.template, + ...(handoff.identityLabel !== undefined ? { identityLabel: handoff.identityLabel } : {}), + }, + onAuthorizationStarted: () => { + trackEvent("connection_reconnected", { + integration_slug: String(integration), + owner: connectionOwner, + success: true, + }); + }, + onError: () => { + trackEvent("connection_reconnected", { + integration_slug: String(integration), + owner: connectionOwner, + success: false, + }); + }, + onSuccess: () => { + toast.success("Reconnected"); + close(); + }, + }); + }, [initialState, allMethods, integration, oauthPopup, close]); const probeAndAutoNameOAuthConnection = async ( connection: OAuthCompletionPayload, @@ -2082,6 +2135,9 @@ function AddAccountModalView(props: AddAccountModalProps) { event.preventDefault() : undefined + } className={cn( "max-h-[85vh] overflow-x-hidden overflow-y-auto", (addingMethod && createCustomMethod) || oauthRegistering || oauthEditing