Merge branch 'dev' into promptless/update-analytics-tables-documentation
Some checks failed
DB migration compat / Check if migrations changed (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled

This commit is contained in:
promptless[bot] 2026-04-21 02:35:14 +00:00
commit d650bb3126
5 changed files with 114 additions and 9 deletions

View File

@ -104,6 +104,10 @@ export const GET = createSmartRouteHandler({
const { turnstileAssessment } = await getRequestContextAndBotChallengeAssessment(query, "oauth_authenticate", tenancy);
if (query.provider_scope && provider.isShared) {
throw new KnownErrors.OAuthExtraScopeNotAvailableWithSharedOAuthKeys();
}
// 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
let projectUserId: string | undefined;
if (query.token) {
@ -120,9 +124,6 @@ export const GET = createSmartRouteHandler({
throw new StatusError(StatusError.Forbidden, "The access token is not valid for this branch");
}
if (query.provider_scope && provider.isShared) {
throw new KnownErrors.OAuthExtraScopeNotAvailableWithSharedOAuthKeys();
}
projectUserId = userId;
}

View File

@ -5,10 +5,9 @@ import { createCrudHandlers } from "@/route-handlers/crud-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { connectedAccountAccessTokenCrud } from "@stackframe/stack-shared/dist/interface/crud/connected-accounts";
import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
import { retrieveOrRefreshAccessToken } from "../../../../access-token-helpers";
import { isSharedAccessTokenBlocked, retrieveOrRefreshAccessToken } from "../../../../access-token-helpers";
export const connectedAccountAccessTokenByAccountCrudHandlers = createLazyProxy(() => createCrudHandlers(connectedAccountAccessTokenCrud, {
@ -29,7 +28,7 @@ export const connectedAccountAccessTokenByAccountCrudHandlers = createLazyProxy(
const provider = { id: providerRaw[0], ...providerRaw[1] };
if (provider.isShared && !getNodeEnvironment().includes('prod') && getEnvVariable('STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS', '') !== 'true') {
if (isSharedAccessTokenBlocked(provider.isShared)) {
throw new KnownErrors.OAuthAccessTokenNotAvailableWithSharedOAuthKeys();
}

View File

@ -5,10 +5,9 @@ import { createCrudHandlers } from "@/route-handlers/crud-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { connectedAccountAccessTokenCrud } from "@stackframe/stack-shared/dist/interface/crud/connected-accounts";
import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
import { retrieveOrRefreshAccessToken } from "../../../access-token-helpers";
import { isSharedAccessTokenBlocked, retrieveOrRefreshAccessToken } from "../../../access-token-helpers";
export const connectedAccountAccessTokenCrudHandlers = createLazyProxy(() => createCrudHandlers(connectedAccountAccessTokenCrud, {
@ -28,7 +27,7 @@ export const connectedAccountAccessTokenCrudHandlers = createLazyProxy(() => cre
const provider = { id: providerRaw[0], ...providerRaw[1] };
if (provider.isShared && !getNodeEnvironment().includes('prod') && getEnvVariable('STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS', '') !== 'true') {
if (isSharedAccessTokenBlocked(provider.isShared)) {
throw new KnownErrors.OAuthAccessTokenNotAvailableWithSharedOAuthKeys();
}

View File

@ -1,10 +1,64 @@
import { OAuthBaseProvider, TokenSet } from "@/oauth/providers/base";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { KnownErrors } from "@stackframe/stack-shared";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist/utils/errors";
import { Result } from "@stackframe/stack-shared/dist/utils/results";
import { extractScopes } from "@stackframe/stack-shared/dist/utils/strings";
/**
* Access tokens minted under Stack Auth's shared OAuth apps must not be handed
* to clients they carry Stack Auth's brand at the provider. Only allowed when
* the deployer explicitly opts in via STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS.
* NOT gated on NODE_ENV the env-var opt-in is the only escape hatch.
*/
export function isSharedAccessTokenBlocked(providerIsShared: boolean): boolean {
if (!providerIsShared) return false;
return getEnvVariable("STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS", "") !== "true";
}
import.meta.vitest?.describe("isSharedAccessTokenBlocked", () => {
const { test, expect, beforeEach, afterEach, vi } = import.meta.vitest!;
beforeEach(() => {
vi.stubEnv("STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS", "");
});
afterEach(() => {
vi.unstubAllEnvs();
});
test("non-shared provider is never blocked, regardless of env var", () => {
expect(isSharedAccessTokenBlocked(false)).toBe(false);
vi.stubEnv("STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS", "true");
expect(isSharedAccessTokenBlocked(false)).toBe(false);
});
test("shared provider is blocked when env var is unset or empty", () => {
expect(isSharedAccessTokenBlocked(true)).toBe(true);
});
test("shared provider is blocked for any value other than the literal 'true'", () => {
for (const v of ["false", "1", "TRUE", "yes", " true "]) {
vi.stubEnv("STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS", v);
expect(isSharedAccessTokenBlocked(true)).toBe(true);
}
});
test("shared provider is allowed only when env var === 'true'", () => {
vi.stubEnv("STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS", "true");
expect(isSharedAccessTokenBlocked(true)).toBe(false);
});
test("result does not depend on NODE_ENV", () => {
for (const nodeEnv of ["production", "development", "test", "preview", ""]) {
vi.stubEnv("NODE_ENV", nodeEnv);
vi.stubEnv("STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS", "");
expect(isSharedAccessTokenBlocked(true)).toBe(true);
vi.stubEnv("STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS", "true");
expect(isSharedAccessTokenBlocked(true)).toBe(false);
}
});
});
/**
* Retrieves a valid access token for one or more OAuth accounts, or refreshes one if needed.
*

View File

@ -272,3 +272,55 @@ it("should fail if an untrusted after_callback_redirect_url is provided", async
}
`);
});
// Regression: provider_scope against a shared provider must be rejected on
// every authorize path — not only when a link token is present. A malicious
// client would otherwise request elevated scopes under Stack Auth's shared
// OAuth app on a plain sign-in.
it("should reject provider_scope on shared provider for plain sign-in (no link token)", async ({ expect }) => {
const response = await niceBackendFetch("/api/v1/auth/oauth/authorize/spotify", {
redirect: "manual",
query: {
...await Auth.OAuth.getAuthorizeQuery(),
provider_scope: "user-read-private user-library-modify playlist-modify-public",
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "OAUTH_EXTRA_SCOPE_NOT_AVAILABLE_WITH_SHARED_OAUTH_KEYS",
"error": "Extra scopes are not available with shared OAuth keys. Please add your own OAuth keys on the Stack dashboard to use extra scopes.",
},
"headers": Headers {
"x-stack-known-error": "OAUTH_EXTRA_SCOPE_NOT_AVAILABLE_WITH_SHARED_OAUTH_KEYS",
<some fields may have been hidden>,
},
}
`);
});
it("should reject provider_scope on shared provider for account-link flow", async ({ expect }) => {
await Auth.OAuth.signIn();
const response = await niceBackendFetch("/api/v1/auth/oauth/authorize/spotify", {
redirect: "manual",
query: {
...await Auth.OAuth.getAuthorizeQuery(),
type: "link",
provider_scope: "user-read-private user-library-modify",
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "OAUTH_EXTRA_SCOPE_NOT_AVAILABLE_WITH_SHARED_OAUTH_KEYS",
"error": "Extra scopes are not available with shared OAuth keys. Please add your own OAuth keys on the Stack dashboard to use extra scopes.",
},
"headers": Headers {
"x-stack-known-error": "OAUTH_EXTRA_SCOPE_NOT_AVAILABLE_WITH_SHARED_OAUTH_KEYS",
<some fields may have been hidden>,
},
}
`);
});