Fix GH tokens refresh & devtool tabs

This commit is contained in:
Konstantin Wohlwend 2026-05-25 17:50:09 -07:00
parent bb109a5cbc
commit d30962bf66
12 changed files with 154 additions and 89 deletions

View File

@ -2,6 +2,9 @@
This file contains knowledge learned while working on the codebase in Q&A format.
## Q: How are connected-account OAuth tokens stored and refreshed?
A: Connected accounts live in `ProjectUserOAuthAccount`. Stored refresh tokens are in `OAuthToken` (`oauthAccountId`, `scopes`, `isValid`), and cached access tokens are in `OAuthAccessToken` (`expiresAt`, `scopes`, `isValid`). `retrieveOrRefreshAccessToken` first uses an unexpired valid access token with matching scopes; otherwise it looks for valid refresh tokens with matching scopes and invalidates only those that the provider explicitly rejects.
## Q: Which Hexclave rename compatibility layers should be avoided in PR #1475 follow-ups?
A: Do not keep backwards compatibility for the MCP tool name, cross-domain auth query parameter names, `NEXT_PUBLIC_STACK_PORT_PREFIX`, or a parallel `hexclaveAppInternalsSymbol`. For refresh/access cookies, read both legacy Stack and new Hexclave cookie names, but only write the canonical Hexclave cookies.

View File

@ -61,6 +61,7 @@ export const connectedAccountAccessTokenByAccountCrudHandlers = createLazyProxy(
return await retrieveOrRefreshAccessToken({
prisma,
providerInstance,
providerId: params.provider_id,
tenancyId: auth.tenancy.id,
oauthAccountIds: [oauthAccount.id],
scope: data.scope,

View File

@ -56,6 +56,7 @@ export const connectedAccountAccessTokenCrudHandlers = createLazyProxy(() => cre
return await retrieveOrRefreshAccessToken({
prisma,
providerInstance,
providerId: params.provider_id,
tenancyId: auth.tenancy.id,
oauthAccountIds: oauthAccounts.map(a => a.id),
scope: data.scope,

View File

@ -94,15 +94,20 @@ import.meta.vitest?.describe("isSharedAccessTokenBlocked", () => {
export async function retrieveOrRefreshAccessToken(options: {
prisma: Awaited<ReturnType<typeof getPrismaClientForTenancy>>,
providerInstance: OAuthBaseProvider,
providerId: string,
tenancyId: string,
oauthAccountIds: string[],
scope: string | undefined,
errorContext: Record<string, unknown>,
}): Promise<{ access_token: string }> {
const { prisma, providerInstance, tenancyId, oauthAccountIds, scope, errorContext } = options;
const { prisma, providerInstance, providerId, tenancyId, oauthAccountIds, scope, errorContext } = options;
const accountIdFilter = oauthAccountIds.length === 1
? { oauthAccountId: oauthAccountIds[0] }
: { oauthAccountId: { in: oauthAccountIds } };
const reauthorizeDetails = "The stored OAuth refresh token is missing, expired, revoked, or no longer accepted by the OAuth provider. The user needs to re-authorize this connected account.";
const requiredScopeDetails = scope
? `The OAuth connection does not have a usable refresh token with the required scope (${scope}). The user needs to re-authorize this connected account with the requested scope.`
: "The OAuth connection does not have a usable refresh token for this connected account. The user needs to re-authorize this connected account.";
// ====================== retrieve access token if it exists ======================
const accessTokens = await prisma.oAuthAccessToken.findMany({
@ -142,10 +147,15 @@ export async function retrieveOrRefreshAccessToken(options: {
return extractScopes(scope || "").every((s) => t.scopes.includes(s));
});
if (filteredRefreshTokens.length === 0) {
throw new KnownErrors.OAuthConnectionDoesNotHaveRequiredScope();
if (refreshTokens.length === 0) {
throw new KnownErrors.OAuthAccessTokenNotAvailable(providerId, reauthorizeDetails);
}
if (filteredRefreshTokens.length === 0) {
throw new KnownErrors.OAuthAccessTokenNotAvailable(providerId, requiredScopeDetails);
}
let invalidatedRefreshTokenDuringAttempt = false;
for (const token of filteredRefreshTokens) {
const tokenSetResult = await providerInstance.getAccessToken({
refreshToken: token.refreshToken,
@ -162,6 +172,7 @@ export async function retrieveOrRefreshAccessToken(options: {
where: { id: token.id },
data: { isValid: false },
});
invalidatedRefreshTokenDuringAttempt = true;
continue;
}
case "temporarily-unavailable": {
@ -247,5 +258,8 @@ export async function retrieveOrRefreshAccessToken(options: {
}
}
throw new KnownErrors.OAuthConnectionDoesNotHaveRequiredScope();
throw new KnownErrors.OAuthAccessTokenNotAvailable(
providerId,
invalidatedRefreshTokenDuringAttempt ? reauthorizeDetails : requiredScopeDetails,
);
}

View File

@ -217,7 +217,13 @@ export function getOAuthAccessTokenRefreshError(error: unknown, options: {
return { type: "unexpected", cause: error, ...metadata };
}
function processTokenSet(providerName: string, tokenSet: OIDCTokenSet, defaultAccessTokenExpiresInMillis?: number): TokenSet {
type DefaultAccessTokenExpiresInMillis = number | ((tokenSet: OIDCTokenSet) => number | undefined);
function getDefaultAccessTokenExpiresInMillis(tokenSet: OIDCTokenSet, defaultAccessTokenExpiresInMillis?: DefaultAccessTokenExpiresInMillis): number | undefined {
return typeof defaultAccessTokenExpiresInMillis === "function" ? defaultAccessTokenExpiresInMillis(tokenSet) : defaultAccessTokenExpiresInMillis;
}
function processTokenSet(providerName: string, tokenSet: OIDCTokenSet, defaultAccessTokenExpiresInMillis?: DefaultAccessTokenExpiresInMillis): TokenSet {
if (!tokenSet.access_token) {
throw new HexclaveAssertionError(`No access token received from ${providerName}.`, { tokenSet, providerName });
}
@ -225,8 +231,9 @@ function processTokenSet(providerName: string, tokenSet: OIDCTokenSet, defaultAc
// if expires_in or expires_at provided, use that
// otherwise, if defaultAccessTokenExpiresInMillis provided, use that
// otherwise, use 1h, and log an error
const defaultExpiresInMillis = getDefaultAccessTokenExpiresInMillis(tokenSet, defaultAccessTokenExpiresInMillis);
if (!tokenSet.expires_in && !tokenSet.expires_at && !defaultAccessTokenExpiresInMillis) {
if (!tokenSet.expires_in && !tokenSet.expires_at && !defaultExpiresInMillis) {
captureError("processTokenSet", new HexclaveAssertionError(`No expires_in or expires_at received from OAuth provider ${providerName}. Falling back to 1h`, { tokenSetKeys: Object.keys(tokenSet) }));
}
@ -237,8 +244,8 @@ function processTokenSet(providerName: string, tokenSet: OIDCTokenSet, defaultAc
accessTokenExpiredAt: tokenSet.expires_in ?
new Date(Date.now() + tokenSet.expires_in * 1000) :
tokenSet.expires_at ? new Date(tokenSet.expires_at * 1000) :
defaultAccessTokenExpiresInMillis ?
new Date(Date.now() + defaultAccessTokenExpiresInMillis) :
defaultExpiresInMillis ?
new Date(Date.now() + defaultExpiresInMillis) :
new Date(Date.now() + 3600 * 1000),
};
}
@ -249,7 +256,7 @@ export abstract class OAuthBaseProvider {
public readonly scope: string,
public readonly redirectUri: string,
public readonly authorizationExtraParams?: Record<string, string>,
public readonly defaultAccessTokenExpiresInMillis?: number,
public readonly defaultAccessTokenExpiresInMillis?: DefaultAccessTokenExpiresInMillis,
public readonly noPKCE?: boolean,
public readonly openid?: boolean,
public readonly alternativeIssuers?: string[],
@ -262,7 +269,7 @@ export abstract class OAuthBaseProvider {
redirectUri: string,
baseScope: string,
authorizationExtraParams?: Record<string, string>,
defaultAccessTokenExpiresInMillis?: number,
defaultAccessTokenExpiresInMillis?: DefaultAccessTokenExpiresInMillis,
tokenEndpointAuthMethod?: "client_secret_post" | "client_secret_basic",
noPKCE?: boolean,
alternativeIssuers?: string[],

View File

@ -23,10 +23,15 @@ export class GithubProvider extends OAuthBaseProvider {
userinfoEndpoint: "https://api.github.com/user",
redirectUri: getEnvVariable("NEXT_PUBLIC_STACK_API_URL") + "/api/v1/auth/oauth/callback/github",
baseScope: "user:email",
// GitHub token does not expire except for lack of use in a year
// We set a default of 1 year
// GitHub can return either non-expiring OAuth-App-style access tokens, or
// expiring user tokens with refresh tokens. If GitHub gives us expires_in,
// the base provider uses that real value. This fallback is only for older
// responses without explicit expiry: refresh-token responses should be
// treated as short-lived, while access-token-only responses are long-lived
// and are still checked against /user before being returned.
// https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/refreshing-user-access-tokens
// https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/token-expiration-and-revocation#user-token-expired-due-to-github-app-configuration
defaultAccessTokenExpiresInMillis: 1000 * 60 * 60 * 8, // 8 hours
defaultAccessTokenExpiresInMillis: (tokenSet) => tokenSet.refresh_token ? 1000 * 60 * 60 * 8 : 1000 * 60 * 60 * 24 * 365,
...options,
}));
}

View File

@ -76,6 +76,7 @@ function PageClientInner() {
const [projectOnboardingStates, setProjectOnboardingStates] = useState<Map<string, ProjectOnboardingState | null>>(new Map());
const [loadingStatuses, setLoadingStatuses] = useState(true);
const [projectName, setProjectName] = useState(displayNameFromSearch ?? "");
const hasProjectName = projectName.trim().length > 0;
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const [creatingTeam, setCreatingTeam] = useState(false);
const [creatingProject, setCreatingProject] = useState(false);
@ -376,6 +377,7 @@ function PageClientInner() {
</DesignButton>
<DesignButton
className="rounded-xl"
disabled={!hasProjectName || creatingProject}
loading={creatingProject}
onClick={() => {
if (!beginPendingAction(creatingProjectRef, setCreatingProject)) {

View File

@ -3,6 +3,12 @@ import { localhostUrl } from "../../../../helpers/ports";
import { Auth, backendContext, createMailbox, niceBackendFetch } from "../../../backend-helpers";
const mockOAuthUrl = (path: string) => localhostUrl("14", path);
const reauthorizeAccessTokenDetails = "The stored OAuth refresh token is missing, expired, revoked, or no longer accepted by the OAuth provider. The user needs to re-authorize this connected account.";
const spotifyAccessTokenNotAvailableError = `Failed to retrieve an OAuth access token for the connected account (provider: spotify). ${reauthorizeAccessTokenDetails}`;
function missingScopeDetails(scope: string) {
return `The OAuth connection does not have a usable refresh token with the required scope (${scope}). The user needs to re-authorize this connected account with the requested scope.`;
}
it("should use the connected account access token to access the userinfo endpoint of the oauth provider", async ({ expect }) => {
await Auth.OAuth.signIn();
@ -122,6 +128,28 @@ it("should refresh the connected account access token when it is revoked from th
`);
});
it("should return a scope-specific error when connected account tokens do not have the requested scope", async ({ expect }) => {
await Auth.OAuth.signIn();
const scope = "missing-scope";
const details = missingScopeDetails(scope);
const response = await niceBackendFetch("/api/v1/connected-accounts/me/spotify/access-token", {
accessType: "client",
method: "POST",
body: { scope },
});
expect(response.status).toBe(400);
expect(response.body).toEqual({
code: "OAUTH_ACCESS_TOKEN_NOT_AVAILABLE",
details: {
provider: "spotify",
details,
},
error: `Failed to retrieve an OAuth access token for the connected account (provider: spotify). ${details}`,
});
});
it("should prompt the user to re-authorize the connected account when the refresh token is revoked from the oauth provider", async ({ expect }) => {
await Auth.OAuth.signIn();
@ -175,19 +203,15 @@ it("should prompt the user to re-authorize the connected account when the refres
scope: "openid",
},
});
expect(response5).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "OAUTH_CONNECTION_DOES_NOT_HAVE_REQUIRED_SCOPE",
"error": "The OAuth connection does not have the required scope.",
},
"headers": Headers {
"x-stack-known-error": "OAUTH_CONNECTION_DOES_NOT_HAVE_REQUIRED_SCOPE",
<some fields may have been hidden>,
},
}
`);
expect(response5.status).toBe(400);
expect(response5.body).toEqual({
code: "OAUTH_ACCESS_TOKEN_NOT_AVAILABLE",
details: {
provider: "spotify",
details: reauthorizeAccessTokenDetails,
},
error: spotifyAccessTokenNotAvailableError,
});
});
it("should handle access_denied error gracefully when refreshing token", async ({ expect }) => {
@ -228,7 +252,7 @@ it("should handle access_denied error gracefully when refreshing token", async (
}),
});
// Try to get a new access token - should fail gracefully with OAUTH_CONNECTION_DOES_NOT_HAVE_REQUIRED_SCOPE
// Try to get a new access token - should fail gracefully and ask the user to re-authorize
const response3 = await niceBackendFetch("/api/v1/connected-accounts/me/spotify/access-token", {
accessType: "client",
method: "POST",
@ -236,19 +260,15 @@ it("should handle access_denied error gracefully when refreshing token", async (
scope: "openid",
},
});
expect(response3).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "OAUTH_CONNECTION_DOES_NOT_HAVE_REQUIRED_SCOPE",
"error": "The OAuth connection does not have the required scope.",
},
"headers": Headers {
"x-stack-known-error": "OAUTH_CONNECTION_DOES_NOT_HAVE_REQUIRED_SCOPE",
<some fields may have been hidden>,
},
}
`);
expect(response3.status).toBe(400);
expect(response3.body).toEqual({
code: "OAUTH_ACCESS_TOKEN_NOT_AVAILABLE",
details: {
provider: "spotify",
details: reauthorizeAccessTokenDetails,
},
error: spotifyAccessTokenNotAvailableError,
});
});
it("should handle consent_required error gracefully when refreshing token", async ({ expect }) => {
@ -297,19 +317,15 @@ it("should handle consent_required error gracefully when refreshing token", asyn
scope: "openid",
},
});
expect(response3).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "OAUTH_CONNECTION_DOES_NOT_HAVE_REQUIRED_SCOPE",
"error": "The OAuth connection does not have the required scope.",
},
"headers": Headers {
"x-stack-known-error": "OAUTH_CONNECTION_DOES_NOT_HAVE_REQUIRED_SCOPE",
<some fields may have been hidden>,
},
}
`);
expect(response3.status).toBe(400);
expect(response3.body).toEqual({
code: "OAUTH_ACCESS_TOKEN_NOT_AVAILABLE",
details: {
provider: "spotify",
details: reauthorizeAccessTokenDetails,
},
error: spotifyAccessTokenNotAvailableError,
});
});
it("should handle invalid_token error gracefully when refreshing token", async ({ expect }) => {
@ -358,19 +374,15 @@ it("should handle invalid_token error gracefully when refreshing token", async (
scope: "openid",
},
});
expect(response3).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "OAUTH_CONNECTION_DOES_NOT_HAVE_REQUIRED_SCOPE",
"error": "The OAuth connection does not have the required scope.",
},
"headers": Headers {
"x-stack-known-error": "OAUTH_CONNECTION_DOES_NOT_HAVE_REQUIRED_SCOPE",
<some fields may have been hidden>,
},
}
`);
expect(response3.status).toBe(400);
expect(response3.body).toEqual({
code: "OAUTH_ACCESS_TOKEN_NOT_AVAILABLE",
details: {
provider: "spotify",
details: reauthorizeAccessTokenDetails,
},
error: spotifyAccessTokenNotAvailableError,
});
});
it("should handle unauthorized_client error gracefully when refreshing token", async ({ expect }) => {
@ -419,19 +431,15 @@ it("should handle unauthorized_client error gracefully when refreshing token", a
scope: "openid",
},
});
expect(response3).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "OAUTH_CONNECTION_DOES_NOT_HAVE_REQUIRED_SCOPE",
"error": "The OAuth connection does not have the required scope.",
},
"headers": Headers {
"x-stack-known-error": "OAUTH_CONNECTION_DOES_NOT_HAVE_REQUIRED_SCOPE",
<some fields may have been hidden>,
},
}
`);
expect(response3.status).toBe(400);
expect(response3.body).toEqual({
code: "OAUTH_ACCESS_TOKEN_NOT_AVAILABLE",
details: {
provider: "spotify",
details: reauthorizeAccessTokenDetails,
},
error: spotifyAccessTokenNotAvailableError,
});
});
it("should list all connected accounts for the current user", async ({ expect }) => {
@ -810,9 +818,16 @@ it("should get access token for specific account when user has multiple accounts
body: { scope: "openid" },
});
// The mock OAuth server doesn't have a refresh token for this manually created account,
// so it will return an error about not having the required scope
// so it will return a re-authorization error instead of a scope-only error.
expect(response.status).toBe(400);
expect(response.body.code).toBe("OAUTH_CONNECTION_DOES_NOT_HAVE_REQUIRED_SCOPE");
expect(response.body).toEqual({
code: "OAUTH_ACCESS_TOKEN_NOT_AVAILABLE",
details: {
provider: "spotify",
details: reauthorizeAccessTokenDetails,
},
error: spotifyAccessTokenNotAvailableError,
});
});
it("should differentiate between accounts with same provider but different account IDs", async ({ expect }) => {

View File

@ -219,6 +219,7 @@ export function isLocalhost(urlOrString: string | URL) {
if (!url) return false;
if (url.hostname === "localhost" || url.hostname.endsWith(".localhost")) return true;
if (url.hostname.match(/^127\.\d+\.\d+\.\d+$/)) return true;
if (url.hostname === "[::1]" || url.hostname === "::1") return true;
return false;
}
import.meta.vitest?.test("isLocalhost", ({ expect }) => {
@ -228,6 +229,7 @@ import.meta.vitest?.test("isLocalhost", ({ expect }) => {
expect(isLocalhost("http://sub.localhost")).toBe(true);
expect(isLocalhost("http://127.0.0.1")).toBe(true);
expect(isLocalhost("http://127.1.2.3")).toBe(true);
expect(isLocalhost("http://[::1]")).toBe(true);
// Test with non-localhost URLs
expect(isLocalhost("https://example.com")).toBe(false);

View File

@ -4,6 +4,7 @@ import type { RequestLogEntry } from "@stackframe/stack-shared/dist/interface/cl
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
import { isLocalhost } from "@stackframe/stack-shared/dist/utils/urls";
import type { StackClientApp } from "../lib/stack-app";
import { envVars } from "../lib/env";
import { getBaseUrl } from "../lib/stack-app/apps/implementations/common";
import type { HandlerUrlOptions, HandlerUrls, HandlerUrlTarget } from "../lib/stack-app/common";
import { stackAppInternalsSymbol } from "../lib/stack-app/common";
@ -203,6 +204,17 @@ function resolveApiBaseUrl(app: StackClientApp<true>): string {
return getBaseUrl(opts.baseUrl);
}
function shouldShowDashboardTab(app: StackClientApp<true>): boolean {
return envVars.NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR === "true" && isLocalhost(resolveApiBaseUrl(app));
}
function getTabsForApp(app: StackClientApp<true>): { id: TabId; label: string; icon: string }[] {
if (shouldShowDashboardTab(app)) {
return TABS;
}
return TABS.filter((tab) => tab.id !== 'dashboard');
}
function deriveDashboardBaseUrl(apiBaseUrl: string): string {
try {
const url = new URL(apiBaseUrl);
@ -2170,7 +2182,11 @@ function createPanel(
panel.style.height = state.get().panelHeight + 'px';
}
applyPanelMode(state.get().activeTab);
const tabs = getTabsForApp(app);
const storedActiveTab = state.get().activeTab;
const activeTab = tabs.some((tab) => tab.id === storedActiveTab) ? storedActiveTab : DEFAULT_STATE.activeTab;
applyPanelMode(activeTab);
const inner = h('div', { className: 'sdt-panel-inner' });
@ -2186,7 +2202,7 @@ function createPanel(
const trailingControls = h('div', { className: 'sdt-tabbar-actions' }, docsLink, closeBtn);
const tabBar = createTabBar(TABS, state.get().activeTab, (id) => {
const tabBar = createTabBar(tabs, activeTab, (id) => {
state.update({ activeTab: id as TabId });
applyPanelMode(id as TabId, { animate: true });
showTab(id as TabId);
@ -2260,7 +2276,7 @@ function createPanel(
pane.classList.add('sdt-tab-pane-active');
}
showTab(state.get().activeTab);
showTab(activeTab);
function addResizeHandle(edge: 'top' | 'left' | 'top-left') {
const handle = h('div', { className: `sdt-resize-handle sdt-resize-${edge}` });
@ -2361,7 +2377,6 @@ export function createDevTool(app: StackClientApp<true>): () => void {
function openPanel() {
if (panel) return;
state.update({ activeTab: 'overview' });
panel = createPanel(app, state, logStore, closePanelAndPersistClosed);
wrapper.appendChild(panel.element);
}

View File

@ -282,7 +282,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
const result = await this._interface.createProviderAccessToken(providerId, scope || "", session);
return { accessToken: result.access_token };
} catch (err) {
if (!(KnownErrors.OAuthConnectionDoesNotHaveRequiredScope.isInstance(err) || KnownErrors.OAuthConnectionNotConnectedToUser.isInstance(err))) {
if (!(KnownErrors.OAuthAccessTokenNotAvailable.isInstance(err) || KnownErrors.OAuthConnectionDoesNotHaveRequiredScope.isInstance(err) || KnownErrors.OAuthConnectionNotConnectedToUser.isInstance(err))) {
throw err;
}
}
@ -317,7 +317,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
const result = await this._interface.createProviderAccessTokenByAccount(providerId, providerAccountId, scope, session);
return { accessToken: result.access_token };
} catch (err) {
if (KnownErrors.OAuthConnectionDoesNotHaveRequiredScope.isInstance(err) || KnownErrors.OAuthConnectionNotConnectedToUser.isInstance(err)) {
if (KnownErrors.OAuthAccessTokenNotAvailable.isInstance(err) || KnownErrors.OAuthConnectionDoesNotHaveRequiredScope.isInstance(err) || KnownErrors.OAuthConnectionNotConnectedToUser.isInstance(err)) {
return null;
}
throw err;

View File

@ -123,7 +123,7 @@ export class _StackServerAppImplIncomplete<HasTokenStore extends boolean, Projec
const result = await this._interface.createServerProviderAccessToken(userId, providerId, scope || "");
return { accessToken: result.access_token };
} catch (err) {
if (!(KnownErrors.OAuthConnectionDoesNotHaveRequiredScope.isInstance(err) || KnownErrors.OAuthConnectionNotConnectedToUser.isInstance(err))) {
if (!(KnownErrors.OAuthAccessTokenNotAvailable.isInstance(err) || KnownErrors.OAuthConnectionDoesNotHaveRequiredScope.isInstance(err) || KnownErrors.OAuthConnectionNotConnectedToUser.isInstance(err))) {
throw err;
}
}
@ -158,7 +158,7 @@ export class _StackServerAppImplIncomplete<HasTokenStore extends boolean, Projec
const result = await this._interface.createServerProviderAccessTokenByAccount(userId, providerId, providerAccountId, scope || "");
return { accessToken: result.access_token };
} catch (err) {
if (!(KnownErrors.OAuthConnectionDoesNotHaveRequiredScope.isInstance(err) || KnownErrors.OAuthConnectionNotConnectedToUser.isInstance(err))) {
if (!(KnownErrors.OAuthAccessTokenNotAvailable.isInstance(err) || KnownErrors.OAuthConnectionDoesNotHaveRequiredScope.isInstance(err) || KnownErrors.OAuthConnectionNotConnectedToUser.isInstance(err))) {
throw err;
}
}