Skip to content

Commit f89b97b

Browse files
authored
fix connected accounts tokens (#1358)
<!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * OAuth flows now consistently block extra scopes and access tokens for shared OAuth keys, enforcing restrictions earlier in the request processing and across all environments. * **Tests** * Added end-to-end regression tests to verify requests with extra scopes against shared OAuth providers return a 400 response indicating extra scopes/access tokens are not allowed. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 3ea8052 commit f89b97b

5 files changed

Lines changed: 114 additions & 9 deletions

File tree

apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ export const GET = createSmartRouteHandler({
104104

105105
const { turnstileAssessment } = await getRequestContextAndBotChallengeAssessment(query, "oauth_authenticate", tenancy);
106106

107+
if (query.provider_scope && provider.isShared) {
108+
throw new KnownErrors.OAuthExtraScopeNotAvailableWithSharedOAuthKeys();
109+
}
110+
107111
// If a token is provided, store it in the outer info so we can use it to link another user to the account, or to upgrade an anonymous user
108112
let projectUserId: string | undefined;
109113
if (query.token) {
@@ -120,9 +124,6 @@ export const GET = createSmartRouteHandler({
120124
throw new StatusError(StatusError.Forbidden, "The access token is not valid for this branch");
121125
}
122126

123-
if (query.provider_scope && provider.isShared) {
124-
throw new KnownErrors.OAuthExtraScopeNotAvailableWithSharedOAuthKeys();
125-
}
126127
projectUserId = userId;
127128
}
128129

apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/[provider_account_id]/access-token/crud.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@ import { createCrudHandlers } from "@/route-handlers/crud-handler";
55
import { KnownErrors } from "@stackframe/stack-shared";
66
import { connectedAccountAccessTokenCrud } from "@stackframe/stack-shared/dist/interface/crud/connected-accounts";
77
import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
8-
import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
98
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
109
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
11-
import { retrieveOrRefreshAccessToken } from "../../../../access-token-helpers";
10+
import { isSharedAccessTokenBlocked, retrieveOrRefreshAccessToken } from "../../../../access-token-helpers";
1211

1312

1413
export const connectedAccountAccessTokenByAccountCrudHandlers = createLazyProxy(() => createCrudHandlers(connectedAccountAccessTokenCrud, {
@@ -29,7 +28,7 @@ export const connectedAccountAccessTokenByAccountCrudHandlers = createLazyProxy(
2928

3029
const provider = { id: providerRaw[0], ...providerRaw[1] };
3130

32-
if (provider.isShared && !getNodeEnvironment().includes('prod') && getEnvVariable('STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS', '') !== 'true') {
31+
if (isSharedAccessTokenBlocked(provider.isShared)) {
3332
throw new KnownErrors.OAuthAccessTokenNotAvailableWithSharedOAuthKeys();
3433
}
3534

apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/access-token/crud.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@ import { createCrudHandlers } from "@/route-handlers/crud-handler";
55
import { KnownErrors } from "@stackframe/stack-shared";
66
import { connectedAccountAccessTokenCrud } from "@stackframe/stack-shared/dist/interface/crud/connected-accounts";
77
import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
8-
import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
98
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
109
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
11-
import { retrieveOrRefreshAccessToken } from "../../../access-token-helpers";
10+
import { isSharedAccessTokenBlocked, retrieveOrRefreshAccessToken } from "../../../access-token-helpers";
1211

1312

1413
export const connectedAccountAccessTokenCrudHandlers = createLazyProxy(() => createCrudHandlers(connectedAccountAccessTokenCrud, {
@@ -28,7 +27,7 @@ export const connectedAccountAccessTokenCrudHandlers = createLazyProxy(() => cre
2827

2928
const provider = { id: providerRaw[0], ...providerRaw[1] };
3029

31-
if (provider.isShared && !getNodeEnvironment().includes('prod') && getEnvVariable('STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS', '') !== 'true') {
30+
if (isSharedAccessTokenBlocked(provider.isShared)) {
3231
throw new KnownErrors.OAuthAccessTokenNotAvailableWithSharedOAuthKeys();
3332
}
3433

apps/backend/src/app/api/latest/connected-accounts/access-token-helpers.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,64 @@
11
import { OAuthBaseProvider, TokenSet } from "@/oauth/providers/base";
22
import { getPrismaClientForTenancy } from "@/prisma-client";
33
import { KnownErrors } from "@stackframe/stack-shared";
4+
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
45
import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist/utils/errors";
56
import { Result } from "@stackframe/stack-shared/dist/utils/results";
67
import { extractScopes } from "@stackframe/stack-shared/dist/utils/strings";
78

9+
/**
10+
* Access tokens minted under Stack Auth's shared OAuth apps must not be handed
11+
* to clients — they carry Stack Auth's brand at the provider. Only allowed when
12+
* the deployer explicitly opts in via STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS.
13+
* NOT gated on NODE_ENV — the env-var opt-in is the only escape hatch.
14+
*/
15+
export function isSharedAccessTokenBlocked(providerIsShared: boolean): boolean {
16+
if (!providerIsShared) return false;
17+
return getEnvVariable("STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS", "") !== "true";
18+
}
19+
20+
import.meta.vitest?.describe("isSharedAccessTokenBlocked", () => {
21+
const { test, expect, beforeEach, afterEach, vi } = import.meta.vitest!;
22+
beforeEach(() => {
23+
vi.stubEnv("STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS", "");
24+
});
25+
afterEach(() => {
26+
vi.unstubAllEnvs();
27+
});
28+
29+
test("non-shared provider is never blocked, regardless of env var", () => {
30+
expect(isSharedAccessTokenBlocked(false)).toBe(false);
31+
vi.stubEnv("STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS", "true");
32+
expect(isSharedAccessTokenBlocked(false)).toBe(false);
33+
});
34+
35+
test("shared provider is blocked when env var is unset or empty", () => {
36+
expect(isSharedAccessTokenBlocked(true)).toBe(true);
37+
});
38+
39+
test("shared provider is blocked for any value other than the literal 'true'", () => {
40+
for (const v of ["false", "1", "TRUE", "yes", " true "]) {
41+
vi.stubEnv("STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS", v);
42+
expect(isSharedAccessTokenBlocked(true)).toBe(true);
43+
}
44+
});
45+
46+
test("shared provider is allowed only when env var === 'true'", () => {
47+
vi.stubEnv("STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS", "true");
48+
expect(isSharedAccessTokenBlocked(true)).toBe(false);
49+
});
50+
51+
test("result does not depend on NODE_ENV", () => {
52+
for (const nodeEnv of ["production", "development", "test", "preview", ""]) {
53+
vi.stubEnv("NODE_ENV", nodeEnv);
54+
vi.stubEnv("STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS", "");
55+
expect(isSharedAccessTokenBlocked(true)).toBe(true);
56+
vi.stubEnv("STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS", "true");
57+
expect(isSharedAccessTokenBlocked(true)).toBe(false);
58+
}
59+
});
60+
});
61+
862
/**
963
* Retrieves a valid access token for one or more OAuth accounts, or refreshes one if needed.
1064
*

apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/authorize.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,3 +272,55 @@ it("should fail if an untrusted after_callback_redirect_url is provided", async
272272
}
273273
`);
274274
});
275+
276+
// Regression: provider_scope against a shared provider must be rejected on
277+
// every authorize path — not only when a link token is present. A malicious
278+
// client would otherwise request elevated scopes under Stack Auth's shared
279+
// OAuth app on a plain sign-in.
280+
it("should reject provider_scope on shared provider for plain sign-in (no link token)", async ({ expect }) => {
281+
const response = await niceBackendFetch("/api/v1/auth/oauth/authorize/spotify", {
282+
redirect: "manual",
283+
query: {
284+
...await Auth.OAuth.getAuthorizeQuery(),
285+
provider_scope: "user-read-private user-library-modify playlist-modify-public",
286+
},
287+
});
288+
expect(response).toMatchInlineSnapshot(`
289+
NiceResponse {
290+
"status": 400,
291+
"body": {
292+
"code": "OAUTH_EXTRA_SCOPE_NOT_AVAILABLE_WITH_SHARED_OAUTH_KEYS",
293+
"error": "Extra scopes are not available with shared OAuth keys. Please add your own OAuth keys on the Stack dashboard to use extra scopes.",
294+
},
295+
"headers": Headers {
296+
"x-stack-known-error": "OAUTH_EXTRA_SCOPE_NOT_AVAILABLE_WITH_SHARED_OAUTH_KEYS",
297+
<some fields may have been hidden>,
298+
},
299+
}
300+
`);
301+
});
302+
303+
it("should reject provider_scope on shared provider for account-link flow", async ({ expect }) => {
304+
await Auth.OAuth.signIn();
305+
const response = await niceBackendFetch("/api/v1/auth/oauth/authorize/spotify", {
306+
redirect: "manual",
307+
query: {
308+
...await Auth.OAuth.getAuthorizeQuery(),
309+
type: "link",
310+
provider_scope: "user-read-private user-library-modify",
311+
},
312+
});
313+
expect(response).toMatchInlineSnapshot(`
314+
NiceResponse {
315+
"status": 400,
316+
"body": {
317+
"code": "OAUTH_EXTRA_SCOPE_NOT_AVAILABLE_WITH_SHARED_OAUTH_KEYS",
318+
"error": "Extra scopes are not available with shared OAuth keys. Please add your own OAuth keys on the Stack dashboard to use extra scopes.",
319+
},
320+
"headers": Headers {
321+
"x-stack-known-error": "OAUTH_EXTRA_SCOPE_NOT_AVAILABLE_WITH_SHARED_OAUTH_KEYS",
322+
<some fields may have been hidden>,
323+
},
324+
}
325+
`);
326+
});

0 commit comments

Comments
 (0)