diff --git a/.github/workflows/db-migration-backwards-compatibility.yaml b/.github/workflows/db-migration-backwards-compatibility.yaml index e9700c67f1..b6f15cbe1e 100644 --- a/.github/workflows/db-migration-backwards-compatibility.yaml +++ b/.github/workflows/db-migration-backwards-compatibility.yaml @@ -1,4 +1,4 @@ -name: DB migrations are backwards-compatible +name: DB migration compat on: push: @@ -48,7 +48,7 @@ jobs: fi backwards-compatibility: - name: Test migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code + name: Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code needs: check-migrations-changed if: needs.check-migrations-changed.outputs.migrations_changed == 'true' runs-on: ubicloud-standard-8 @@ -235,6 +235,186 @@ jobs: if: always() run: docker compose -f docker/dependencies/docker.compose.yaml logs + forward-compatibility: + name: Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations + needs: [check-migrations-changed, backwards-compatibility] + if: always() && needs.backwards-compatibility.result == 'failure' + runs-on: ubicloud-standard-8 + env: + NODE_ENV: test + STACK_ENABLE_HARDCODED_PASSKEY_CHALLENGE_FOR_TESTING: yes + STACK_DATABASE_CONNECTION_STRING: "postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:8128/stackframe" + + steps: + # First, checkout the base branch to get its migrations + - name: Checkout base branch + uses: actions/checkout@v6 + with: + ref: ${{ needs.check-migrations-changed.outputs.base_branch }} + path: base-branch + + - name: Save base branch migrations + run: | + mkdir -p saved-migrations + cp -r base-branch/apps/backend/prisma/migrations/* saved-migrations/ + + # Now checkout current branch (new code) + - name: Checkout current branch + uses: actions/checkout@v6 + with: + path: current-branch + + # Move current branch to the root for the rest of the workflow + - name: Setup working directory + run: | + shopt -s dotglob + mv current-branch/* . + rm -rf current-branch base-branch + + # Replace current branch's migrations with base branch's (old) migrations + - name: Replace migrations with base branch migrations + run: | + rm -rf apps/backend/prisma/migrations/* + cp -r saved-migrations/* apps/backend/prisma/migrations/ + rm -rf saved-migrations + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22.x + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + # Start Docker Compose in the background + - name: Start Docker Compose in background + uses: JarvusInnovations/background-action@v1.0.7 + with: + run: docker compose -f docker/dependencies/docker.compose.yaml up --pull always -d & + wait-on: /dev/null + tail: true + wait-for: 3s + log-output-if: true + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Create .env.test.local file for apps/backend + run: cp apps/backend/.env.development apps/backend/.env.test.local + + - name: Create .env.test.local file for apps/dashboard + run: cp apps/dashboard/.env.development apps/dashboard/.env.test.local + + - name: Create .env.test.local file for apps/e2e + run: cp apps/e2e/.env.development apps/e2e/.env.test.local + + - name: Create .env.test.local file for docs + run: cp docs/.env.development docs/.env.test.local + + - name: Create .env.test.local file for examples/cjs-test + run: cp examples/cjs-test/.env.development examples/cjs-test/.env.test.local + + - name: Create .env.test.local file for examples/demo + run: cp examples/demo/.env.development examples/demo/.env.test.local + + - name: Create .env.test.local file for examples/docs-examples + run: cp examples/docs-examples/.env.development examples/docs-examples/.env.test.local + + - name: Create .env.test.local file for examples/e-commerce + run: cp examples/e-commerce/.env.development examples/e-commerce/.env.test.local + + - name: Create .env.test.local file for examples/middleware + run: cp examples/middleware/.env.development examples/middleware/.env.test.local + + - name: Create .env.test.local file for examples/supabase + run: cp examples/supabase/.env.development examples/supabase/.env.test.local + + - name: Create .env.test.local file for examples/convex + run: cp examples/convex/.env.development examples/convex/.env.test.local + + - name: Build + run: pnpm build + + - name: Wait on Postgres + run: pnpm run wait-until-postgres-is-ready:pg_isready + + - name: Wait on Inbucket + run: pnpx wait-on tcp:localhost:8129 + + - name: Wait on Svix + run: pnpx wait-on tcp:localhost:8113 + + - name: Wait on ClickHouse + run: pnpx wait-on http://localhost:8136/ping + + - name: Initialize database + run: pnpm run db:init + + - name: Start stack-backend in background + uses: JarvusInnovations/background-action@v1.0.7 + with: + run: pnpm run start:backend --log-order=stream & + wait-on: | + http://localhost:8102 + tail: true + wait-for: 30s + log-output-if: true + + - name: Start stack-dashboard in background + uses: JarvusInnovations/background-action@v1.0.7 + with: + run: pnpm run start:dashboard --log-order=stream & + wait-on: | + http://localhost:8101 + tail: true + wait-for: 30s + log-output-if: true + + - name: Start mock-oauth-server in background + uses: JarvusInnovations/background-action@v1.0.7 + with: + run: pnpm run start:mock-oauth-server --log-order=stream & + wait-on: | + http://localhost:8102 + tail: true + wait-for: 30s + log-output-if: true + + - name: Start run-email-queue in background + uses: JarvusInnovations/background-action@v1.0.7 + with: + run: pnpm -C apps/backend run run-email-queue --log-order=stream & + wait-on: | + http://localhost:8102 + tail: true + wait-for: 30s + log-output-if: true + + - name: Start run-cron-jobs in background + uses: JarvusInnovations/background-action@v1.0.7 + if: ${{ hashFiles('apps/backend/scripts/run-cron-jobs.ts') != '' }} + with: + run: pnpm -C apps/backend run with-env:dev tsx scripts/run-cron-jobs.ts --log-order=stream & + wait-on: | + http://localhost:8102 + tail: true + wait-for: 30s + log-output-if: true + + - name: Wait 10 seconds + run: sleep 10 + + # Run tests: current branch code with base branch (old) migrations + - name: Run tests (current branch code with base branch migrations) + run: pnpm test + + - name: Verify data integrity + run: pnpm run verify-data-integrity --no-bail + + - name: Print Docker Compose logs + if: always() + run: docker compose -f docker/dependencies/docker.compose.yaml logs + # This job runs when migrations haven't changed, ensuring the workflow succeeds skip-unchanged: name: No migration changes (skipped) diff --git a/AGENTS.md b/AGENTS.md index b76f833c39..fde90ebf10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,7 +100,7 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub - NEVER implement a hacky solution without EXPLICIT approval from the user. Always go the extra mile to make sure the solution is clean, maintainable, and robust. - Fail early, fail loud. Fail fast with an error instead of silently continuing. - Do NOT use `as`/`any`/type casts or anything else like that to bypass the type system unless you specifically asked the user about it. Most of the time a place where you would use type casts is not one where you actually need them. Avoid wherever possible. -- When writing database migration files, assume that we have >1,000,000 rows in every table (unless otherwise specified). This means you may have to use CONDITIONALLY_REPEAT_MIGRATION_SENTINEL to avoid running the migration and things like concurrent index builds; see the existing migrations for examples. One common pattern is to add a temporary extra boolean column +- When writing database migration files, assume that we have >1,000,000 rows in every table (unless otherwise specified). This means you may have to use CONDITIONALLY_REPEAT_MIGRATION_SENTINEL to avoid running the migration and things like concurrent index builds; see the existing migrations for examples. One common pattern is to add a temporary index or extra boolean column marking whether the row has already been migrated (then deleting the column at the end). - Each migration file runs in its own transaction with a relatively short timeout. Split long-running operations into separate migration files to avoid timeouts. For example, when adding CHECK constraints, use `NOT VALID` in one migration, then `VALIDATE CONSTRAINT` in a separate migration file. - Note that each database migration file is executed in a single transaction. Even with the run-outside-transaction sentinel, the transaction will still continue during the entire migration file. If you want to split things up into multiple transactions, put it into their own migration files. - When writing database migration files, ALWAYS ALWAYS add tests for all the potential edge cases! See the folder structure of the other migrations to see how that works. diff --git a/apps/backend/prisma/migrations/20260216120000_project_require_publishable_client_key/migration.sql b/apps/backend/prisma/migrations/20260216120000_project_require_publishable_client_key/migration.sql new file mode 100644 index 0000000000..496c71b1e3 --- /dev/null +++ b/apps/backend/prisma/migrations/20260216120000_project_require_publishable_client_key/migration.sql @@ -0,0 +1,36 @@ +-- Create temporary expression index to speed up the migration +-- (B-tree on the specific JSONB path, not GIN on the whole column, +-- so the index is actually used by the #>> WHERE clause) +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE INDEX CONCURRENTLY IF NOT EXISTS "temp_project_require_publishable_client_key_idx" +ON /* SCHEMA_NAME_SENTINEL */."Project" +USING GIN ("projectConfigOverride"); +-- SPLIT_STATEMENT_SENTINEL + +-- Set requirePublishableClientKey to true for existing projects when missing +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- CONDITIONALLY_REPEAT_MIGRATION_SENTINEL +WITH to_update AS ( + SELECT "id" + FROM "Project" + WHERE "projectConfigOverride" IS NULL + OR NOT "projectConfigOverride" ? 'project.requirePublishableClientKey' + LIMIT 10000 +) +UPDATE "Project" p +SET "projectConfigOverride" = jsonb_set( + COALESCE(p."projectConfigOverride", '{}'::jsonb), + '{project.requirePublishableClientKey}', + 'true'::jsonb, + true +) +FROM to_update tu +WHERE p."id" = tu."id" +RETURNING true AS should_repeat_migration; +-- SPLIT_STATEMENT_SENTINEL + +-- Clean up temporary index +DROP INDEX IF EXISTS "temp_project_require_publishable_client_key_idx"; diff --git a/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx index 3a52c4e248..aca1a9b49e 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx @@ -1,4 +1,4 @@ -import { checkApiKeySet } from "@/lib/internal-api-keys"; +import { checkApiKeySet, throwCheckApiKeySetError } from "@/lib/internal-api-keys"; import { getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; import { decodeAccessToken, oauthCookieSchema } from "@/lib/tokens"; import { getProjectBranchFromClientId, getProvider } from "@/oauth"; @@ -60,8 +60,9 @@ export const GET = createSmartRouteHandler({ throw new KnownErrors.InvalidOAuthClientIdOrSecret(query.client_id); } - if (!(await checkApiKeySet(tenancy.project.id, { publishableClientKey: query.client_secret }))) { - throw new KnownErrors.InvalidPublishableClientKey(tenancy.project.id); + const keyCheck = await checkApiKeySet(tenancy.project.id, { publishableClientKey: query.client_secret }); + if (keyCheck.status === "error") { + throwCheckApiKeySetError(keyCheck.error, tenancy.project.id, new KnownErrors.InvalidPublishableClientKey(tenancy.project.id)); } const providerRaw = Object.entries(tenancy.config.auth.oauth.providers).find(([providerId, _]) => providerId === params.provider_id); diff --git a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx index a202fa63bd..fbac9c757e 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx @@ -1,4 +1,5 @@ import { usersCrudHandlers } from "@/app/api/latest/users/crud"; +import { checkApiKeySet, throwCheckApiKeySetError } from "@/lib/internal-api-keys"; import { createOAuthUserAndAccount, findExistingOAuthAccount, handleOAuthEmailMergeStrategy, linkOAuthAccountToUser } from "@/lib/oauth"; import { isAcceptedNativeAppUrl, validateRedirectUrl } from "@/lib/redirect-urls"; import { Tenancy, getTenancy } from "@/lib/tenancies"; @@ -126,6 +127,11 @@ const handler = createSmartRouteHandler({ const provider = { id: providerRaw[0], ...providerRaw[1] }; + const keyCheck = await checkApiKeySet(tenancy.project.id, { publishableClientKey: outerInfo.publishableClientKey }); + if (keyCheck.status === "error") { + throwCheckApiKeySetError(keyCheck.error, tenancy.project.id, new KnownErrors.InvalidPublishableClientKey(tenancy.project.id)); + } + const providerObj = await getProvider(provider as any); let callbackResult: Awaited>; try { diff --git a/apps/backend/src/app/api/latest/auth/oauth/token/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/token/route.tsx index ac7d61f4a5..d3645b8e8f 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/token/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/token/route.tsx @@ -1,4 +1,6 @@ -import { oauthServer } from "@/oauth"; +import { checkApiKeySet, throwCheckApiKeySetError } from "@/lib/internal-api-keys"; +import { getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; +import { getProjectBranchFromClientId, oauthServer } from "@/oauth"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { InvalidClientError, InvalidGrantError, InvalidRequestError, Request as OAuthRequest, Response as OAuthResponse, ServerError } from "@node-oauth/oauth2-server"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; @@ -15,6 +17,8 @@ export const POST = createSmartRouteHandler({ request: yupObject({ body: yupObject({ grant_type: yupString().oneOf(["authorization_code", "refresh_token"]).defined(), + client_id: yupString().optional(), + client_secret: yupString().optional(), }).unknown().defined(), }).defined(), response: yupObject({ @@ -24,6 +28,25 @@ export const POST = createSmartRouteHandler({ headers: yupMixed().defined(), }), async handler(req, fullReq) { + // Pre-validate the publishable client key to provide specific error messages + // before the OAuth library processes the request + const clientId = req.body.client_id; + const clientSecret = req.body.client_secret; + + if (clientId) { + const tenancy = await getSoleTenancyFromProjectBranch(...getProjectBranchFromClientId(clientId), true); + if (tenancy) { + if (clientSecret) { + const keyCheck = await checkApiKeySet(tenancy.project.id, { publishableClientKey: clientSecret }); + if (keyCheck.status === "error") { + throwCheckApiKeySetError(keyCheck.error, tenancy.project.id, new KnownErrors.InvalidOAuthClientIdOrSecret()); + } + } else if (tenancy.config.project.requirePublishableClientKey) { + throw new KnownErrors.PublishableClientKeyRequiredForProject(tenancy.project.id); + } + } + } + const oauthRequest = new OAuthRequest({ headers: { ...fullReq.headers, diff --git a/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx b/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx index 5fee1b9db6..89c6bfb41f 100644 --- a/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx +++ b/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx @@ -1,15 +1,27 @@ -import { getBranchConfigOverrideQuery, getEnvironmentConfigOverrideQuery, overrideBranchConfigOverride, overrideEnvironmentConfigOverride, setBranchConfigOverride, setBranchConfigOverrideSource, setEnvironmentConfigOverride, validateBranchConfigOverride, validateEnvironmentConfigOverride } from "@/lib/config"; +import { + getBranchConfigOverrideQuery, + getEnvironmentConfigOverrideQuery, + getProjectConfigOverrideQuery, + overrideBranchConfigOverride, + overrideEnvironmentConfigOverride, + overrideProjectConfigOverride, + setBranchConfigOverride, + setBranchConfigOverrideSource, + setEnvironmentConfigOverride, + setProjectConfigOverride, + validateBranchConfigOverride, + validateEnvironmentConfigOverride, +} from "@/lib/config"; import { enqueueExternalDbSync } from "@/lib/external-db-sync-queue"; import { globalPrismaClient, rawQuery } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { branchConfigSchema, environmentConfigSchema, getConfigOverrideErrors, migrateConfigOverride } from "@stackframe/stack-shared/dist/config/schema"; +import { branchConfigSchema, environmentConfigSchema, getConfigOverrideErrors, migrateConfigOverride, projectConfigSchema } from "@stackframe/stack-shared/dist/config/schema"; import { adaptSchema, adminAuthTypeSchema, branchConfigSourceSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; import * as yup from "yup"; - type BranchConfigSourceApi = yup.InferType; -const levelSchema = yupString().oneOf(["branch", "environment"]).defined(); +const levelSchema = yupString().oneOf(["project", "branch", "environment"]).defined(); function shouldEnqueueExternalDbSync(config: unknown): boolean { if (!config || typeof config !== "object") return false; @@ -25,6 +37,24 @@ function shouldEnqueueExternalDbSync(config: unknown): boolean { } const levelConfigs = { + project: { + schema: projectConfigSchema, + migrate: (config: any) => migrateConfigOverride("project", config), + get: (options: { projectId: string, branchId: string }) => + rawQuery(globalPrismaClient, getProjectConfigOverrideQuery({ projectId: options.projectId })), + set: async (options: { projectId: string, branchId: string, config: any, source?: BranchConfigSourceApi }) => { + await setProjectConfigOverride({ + projectId: options.projectId, + projectConfigOverride: options.config, + }); + }, + override: (options: { projectId: string, branchId: string, config: any }) => + overrideProjectConfigOverride({ + projectId: options.projectId, + projectConfigOverrideOverride: options.config, + }), + requiresSource: false, + }, branch: { schema: branchConfigSchema, migrate: (config: any) => migrateConfigOverride("branch", config), @@ -131,7 +161,7 @@ const writeResponseSchema = yupObject({ async function parseAndValidateConfig( configString: string, - levelConfig: typeof levelConfigs["branch" | "environment"] + levelConfig: typeof levelConfigs["branch" | "environment" | "project"] ) { let parsedConfig; try { @@ -153,9 +183,10 @@ async function parseAndValidateConfig( } async function warnOnValidationFailure( - levelConfig: typeof levelConfigs["branch" | "environment"], + levelConfig: typeof levelConfigs[keyof typeof levelConfigs], options: { projectId: string, branchId: string, config: any }, ) { + if (!("validate" in levelConfig)) return; try { const validationResult = await levelConfig.validate(options); if (validationResult.status === "error") { diff --git a/apps/backend/src/lib/internal-api-keys.tsx b/apps/backend/src/lib/internal-api-keys.tsx index be803d3cc9..6e21178398 100644 --- a/apps/backend/src/lib/internal-api-keys.tsx +++ b/apps/backend/src/lib/internal-api-keys.tsx @@ -1,24 +1,49 @@ // TODO remove and replace with CRUD handler -import { RawQuery, globalPrismaClient, rawQuery } from '@/prisma-client'; import { ApiKeySet, Prisma } from '@/generated/prisma/client'; +import { RawQuery, globalPrismaClient, rawQuery } from '@/prisma-client'; import { InternalApiKeysCrud } from '@stackframe/stack-shared/dist/interface/crud/internal-api-keys'; import { yupString } from '@stackframe/stack-shared/dist/schema-fields'; import { typedIncludes } from '@stackframe/stack-shared/dist/utils/arrays'; import { generateSecureRandomString } from '@stackframe/stack-shared/dist/utils/crypto'; -import { getNodeEnvironment } from '@stackframe/stack-shared/dist/utils/env'; +import { KnownError, KnownErrors } from '@stackframe/stack-shared/dist/known-errors'; import { StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors'; +import { publishableClientKeyNotNecessarySentinel } from '@stackframe/stack-shared/dist/utils/oauth'; +import { Result } from '@stackframe/stack-shared/dist/utils/results'; import { generateUuid } from '@stackframe/stack-shared/dist/utils/uuids'; +import { getRenderedProjectConfigQuery } from './config'; export const publishableClientKeyHeaderSchema = yupString().matches(/^[a-zA-Z0-9_-]*$/); export const secretServerKeyHeaderSchema = publishableClientKeyHeaderSchema; export const superSecretAdminKeyHeaderSchema = secretServerKeyHeaderSchema; -export function checkApiKeySetQuery(projectId: string, key: KeyType): RawQuery { +export type CheckApiKeySetError = "invalid-key" | "publishable-key-required"; + +export function throwCheckApiKeySetError(error: CheckApiKeySetError, projectId: string, invalidKeyError: KnownError): never { + if (error === "publishable-key-required") { + throw new KnownErrors.PublishableClientKeyRequiredForProject(projectId); + } + throw invalidKeyError; +} + +export function checkApiKeySetQuery(projectId: string, key: KeyType): RawQuery>> { key = validateKeyType(key); const keyType = Object.keys(key)[0] as keyof KeyType; const keyValue = key[keyType]; + if (keyType === "publishableClientKey" && keyValue === publishableClientKeyNotNecessarySentinel) { + return RawQuery.then( + getRenderedProjectConfigQuery({ projectId }), + async (configPromise) => { + const config = await configPromise; + if (config.project.requirePublishableClientKey) { + return Result.error("publishable-key-required" as const); + } + return Result.ok(undefined); + }, + ); + } + const whereClause = Prisma.sql` ${Prisma.raw(JSON.stringify(keyType))} = ${keyValue} `; @@ -34,35 +59,17 @@ export function checkApiKeySetQuery(projectId: string, key: KeyType): RawQuery ${new Date()} `, - postProcess: (rows) => rows[0]?.result === "t", + postProcess: async (rows): Promise> => + rows[0]?.result === "t" ? Result.ok(undefined) : Result.error("invalid-key"), }; } -export async function checkApiKeySet(projectId: string, key: KeyType): Promise { +export async function checkApiKeySet(projectId: string, key: KeyType): Promise> { const result = await rawQuery(globalPrismaClient, checkApiKeySetQuery(projectId, key)); - // In non-prod environments, let's also call the legacy function and ensure the result is the same - if (!getNodeEnvironment().includes("prod")) { - const legacy = await checkApiKeySetLegacy(projectId, key); - if (legacy !== result) { - throw new StackAssertionError("checkApiKeySet result mismatch", { - result, - legacy, - }); - } - } - return result; } -async function checkApiKeySetLegacy(projectId: string, key: KeyType): Promise { - const set = await getApiKeySet(projectId, key); - if (!set) return false; - if (set.manually_revoked_at_millis) return false; - if (set.expires_at_millis < Date.now()) return false; - return true; -} - type KeyType = | { publishableClientKey: string } diff --git a/apps/backend/src/oauth/model.tsx b/apps/backend/src/oauth/model.tsx index 9357d74a55..841300b42f 100644 --- a/apps/backend/src/oauth/model.tsx +++ b/apps/backend/src/oauth/model.tsx @@ -47,9 +47,12 @@ export class OAuthModel implements AuthorizationCodeModel { return false; } + // If client_secret is provided, validate it + // Note: The specific error handling (sentinel vs invalid key) is done in the route handlers + // that call this method, as they have more context about the request if (clientSecret) { const keySet = await checkApiKeySet(tenancy.project.id, { publishableClientKey: clientSecret }); - if (!keySet) { + if (keySet.status === "error") { return false; } } diff --git a/apps/backend/src/route-handlers/smart-request.tsx b/apps/backend/src/route-handlers/smart-request.tsx index ff4135a382..e25b71648f 100644 --- a/apps/backend/src/route-handlers/smart-request.tsx +++ b/apps/backend/src/route-handlers/smart-request.tsx @@ -259,31 +259,45 @@ const parseAuth = withTraceSpan('smart request parseAuth', async (req: NextReque const project = await queriesResults.project; if (project === null) throw new KnownErrors.CurrentProjectNotFound(projectId); // this does allow one to probe whether a project exists or not, but that's fine (it's worth the better error messages) const tenancy = await queriesResults.tenancy; + const isClientKeyValid = await queriesResults.isClientKeyValid; + const isServerKeyValid = await queriesResults.isServerKeyValid; + const isAdminKeyValid = await queriesResults.isAdminKeyValid; + const requiresPublishableClientKey = tenancy?.config.project.requirePublishableClientKey ?? true; if (developmentKeyOverride) { if (!["development", "test"].includes(getNodeEnvironment()) && getEnvVariable("STACK_ALLOW_DEVELOPMENT_KEY_OVERRIDE_DESPITE_PRODUCTION", "") !== "this-is-dangerous") { // it's not actually that dangerous, but it changes the security model throw new StatusError(401, "Development key override is only allowed in development or test environments"); } const result = await checkApiKeySet("internal", { superSecretAdminKey: developmentKeyOverride }); - if (!result) throw new StatusError(401, "Invalid development key override"); + if (result.status === "error") throw new StatusError(401, "Invalid development key override"); } else if (adminAccessToken) { // TODO put this into the bundled queries above (not so important because this path is quite rare) await extractUserFromAdminAccessToken({ token: adminAccessToken, projectId }); // assert that the admin token is valid } else { switch (requestType) { case "client": { - if (!publishableClientKey) throw new KnownErrors.ClientAuthenticationRequired(); - if (!queriesResults.isClientKeyValid) throw new KnownErrors.InvalidPublishableClientKey(projectId); + if (!publishableClientKey) { + if (requiresPublishableClientKey) { + throw new KnownErrors.PublishableClientKeyRequiredForProject(projectId); + } + break; + } + if (isClientKeyValid.status === "error") { + if (isClientKeyValid.error === "publishable-key-required") { + throw new KnownErrors.PublishableClientKeyRequiredForProject(projectId); + } + throw new KnownErrors.InvalidPublishableClientKey(projectId); + } break; } case "server": { if (!secretServerKey) throw new KnownErrors.ServerAuthenticationRequired(); - if (!queriesResults.isServerKeyValid) throw new KnownErrors.InvalidSecretServerKey(projectId); + if (isServerKeyValid.status === "error") throw new KnownErrors.InvalidSecretServerKey(projectId); break; } case "admin": { if (!superSecretAdminKey) throw new KnownErrors.AdminAuthenticationRequired(); - if (!queriesResults.isAdminKeyValid) throw new KnownErrors.InvalidSuperSecretAdminKey(projectId); + if (isAdminKeyValid.status === "error") throw new KnownErrors.InvalidSuperSecretAdminKey(projectId); break; } default: { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/setup-page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/setup-page.tsx index bd8799f57b..ca28869013 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/setup-page.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/setup-page.tsx @@ -27,11 +27,18 @@ const nameClasses = "text-green-600 dark:text-green-500"; export default function SetupPage(props: { toMetrics: () => void }) { const adminApp = useAdminApp(); const [selectedFramework, setSelectedFramework] = useState<'nextjs' | 'react' | 'javascript' | 'python'>('nextjs'); - const [keys, setKeys] = useState<{ projectId: string, publishableClientKey: string, secretServerKey: string } | null>(null); + const [keys, setKeys] = useState<{ projectId: string, publishableClientKey?: string, secretServerKey: string } | null>(null); + const projectConfig = adminApp.useProject().useConfig(); + const requirePublishableClientKey = projectConfig.project.requirePublishableClientKey; + const publishableClientKeyValue = keys?.publishableClientKey ?? "..."; + const optionalPublishableClientKeyProp = (indent: string) => + requirePublishableClientKey ? `\n${indent}publishableClientKey: "${publishableClientKeyValue}",` : ""; + const optionalPublishableClientKeyHeader = (indent: string) => + requirePublishableClientKey ? `\n${indent}'x-stack-publishable-client-key': "${publishableClientKeyValue}",` : ""; const onGenerateKeys = async () => { const newKey = await adminApp.createInternalApiKey({ - hasPublishableClientKey: true, + hasPublishableClientKey: requirePublishableClientKey, hasSecretServerKey: true, hasSuperSecretAdminKey: false, expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 200), @@ -40,7 +47,7 @@ export default function SetupPage(props: { toMetrics: () => void }) { setKeys({ projectId: adminApp.projectId, - publishableClientKey: newKey.publishableClientKey!, + publishableClientKey: newKey.publishableClientKey ?? undefined, secretServerKey: newKey.secretServerKey!, }); }; @@ -128,8 +135,7 @@ export default function SetupPage(props: { toMetrics: () => void }) { export const stackClientApp = new StackClientApp({ // You should store these in environment variables - projectId: "${keys?.projectId ?? "..."}", - publishableClientKey: "${keys?.publishableClientKey ?? "..."}", + projectId: "${keys?.projectId ?? "..."}",${optionalPublishableClientKeyProp(" ")} tokenStore: "cookie", redirectMethod: { useNavigate, @@ -244,8 +250,7 @@ export default function SetupPage(props: { toMetrics: () => void }) { const stackServerApp = new StackServerApp({ // You should store these in environment variables based on your project setup - projectId: "${keys?.projectId ?? "..."}", - publishableClientKey: "${keys?.publishableClientKey ?? "..."}", + projectId: "${keys?.projectId ?? "..."}",${optionalPublishableClientKeyProp(" ")} secretServerKey: "${keys?.secretServerKey ?? "..."}", tokenStore: "memory", }); @@ -262,8 +267,7 @@ export default function SetupPage(props: { toMetrics: () => void }) { const stackClientApp = new StackClientApp({ // You should store these in environment variables - projectId: "your-project-id", - publishableClientKey: "your-publishable-client-key", + projectId: "your-project-id",${optionalPublishableClientKeyProp(" ")} tokenStore: "cookie", }); `} @@ -378,8 +382,7 @@ export default function SetupPage(props: { toMetrics: () => void }) { headers={ 'x-stack-access-type': 'server', # You should store these in environment variables - 'x-stack-project-id': "${keys?.projectId ?? "..."}", - 'x-stack-publishable-client-key': "${keys?.publishableClientKey ?? "..."}", + 'x-stack-project-id': "${keys?.projectId ?? "..."}",${optionalPublishableClientKeyHeader(" ")} 'x-stack-secret-server-key': "${keys?.secretServerKey ?? "..."}", **kwargs.pop('headers', {}), }, @@ -606,7 +609,7 @@ function GlobeIllustrationInner() { } function StackAuthKeys(props: { - keys: { projectId: string, publishableClientKey: string, secretServerKey: string } | null, + keys: { projectId: string, publishableClientKey?: string, secretServerKey: string } | null, onGenerateKeys: () => Promise, type: 'next' | 'raw', }) { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-keys/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-keys/page-client.tsx index f3f65ec7c5..c095f603a4 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-keys/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-keys/page-client.tsx @@ -3,18 +3,21 @@ import { InternalApiKeyTable } from "@/components/data-table/api-key-table"; import { EnvKeys } from "@/components/env-keys"; import { SmartFormDialog } from "@/components/form-dialog"; import { SelectField } from "@/components/form-fields"; -import { InternalApiKeyFirstView } from "@stackframe/stack"; +import { SettingSwitch } from "@/components/settings"; import { ActionDialog, Button, Typography } from "@/components/ui"; +import { InternalApiKeyFirstView } from "@stackframe/stack"; import { useSearchParams } from "next/navigation"; import { useState } from "react"; import * as yup from "yup"; -import { AppEnabledGuard } from "../app-enabled-guard"; import { PageLayout } from "../page-layout"; import { useAdminApp } from "../use-admin-app"; export default function PageClient() { const stackAdminApp = useAdminApp(); + const project = stackAdminApp.useProject(); + const config = project.useConfig(); + const requirePublishableClientKey = config.project.requirePublishableClientKey; const apiKeySets = stackAdminApp.useInternalApiKeys(); const params = useSearchParams(); const create = params.get("create") === "true"; @@ -31,12 +34,27 @@ export default function PageClient() { } > - + + + { + await project.update({ + requirePublishableClientKey: checked, + }); + }} + /> void, onKeyCreated?: (key: InternalApiKeyFirstView) => void, + requirePublishableClientKey: boolean, }) { const stackAdminApp = useAdminApp(); const params = useSearchParams(); @@ -84,7 +103,7 @@ function CreateDialog(props: { onSubmit={async (values) => { const expiresIn = parseInt(values.expiresIn); const newKey = await stackAdminApp.createInternalApiKey({ - hasPublishableClientKey: true, + hasPublishableClientKey: props.requirePublishableClientKey, hasSecretServerKey: true, hasSuperSecretAdminKey: false, expiresAt: new Date(Date.now() + expiresIn), diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/vercel/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/vercel/page-client.tsx index 7093519183..6280c10688 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/vercel/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/vercel/page-client.tsx @@ -3,8 +3,6 @@ import { EnvKeys } from "@/components/env-keys"; import { InlineCode } from "@/components/inline-code"; import { StyledLink } from "@/components/link"; -import { CaretDownIcon, CaretUpIcon, CheckCircleIcon, CircleIcon, ClockIcon } from "@phosphor-icons/react"; -import { runAsynchronously, runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { Alert, AlertDescription, @@ -20,6 +18,8 @@ import { Typography, cn } from "@/components/ui"; +import { CaretDownIcon, CaretUpIcon, CheckCircleIcon, CircleIcon, ClockIcon } from "@phosphor-icons/react"; +import { runAsynchronously, runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import * as confetti from "canvas-confetti"; import { useEffect, useRef, useState } from "react"; import { AppEnabledGuard } from "../app-enabled-guard"; @@ -28,7 +28,7 @@ import { useAdminApp } from "../use-admin-app"; type GeneratedKeys = { projectId: string, - publishableClientKey: string, + publishableClientKey?: string, secretServerKey: string, }; @@ -63,6 +63,8 @@ const STATUS_META: Record< export default function PageClient() { const adminApp = useAdminApp(); const project = adminApp.useProject(); + const projectConfig = project.useConfig(); + const requirePublishableClientKey = projectConfig.project.requirePublishableClientKey; const [keys, setKeys] = useState(null); const [isGenerating, setIsGenerating] = useState(false); const [error, setError] = useState(null); @@ -80,7 +82,7 @@ export default function PageClient() { runAsynchronouslyWithAlert(async () => { try { const newKey = await adminApp.createInternalApiKey({ - hasPublishableClientKey: true, + hasPublishableClientKey: requirePublishableClientKey, hasSecretServerKey: true, hasSuperSecretAdminKey: false, expiresAt: new Date(Date.now() + TWO_HUNDRED_YEARS_IN_MS), @@ -89,7 +91,7 @@ export default function PageClient() { setKeys({ projectId: adminApp.projectId, - publishableClientKey: newKey.publishableClientKey!, + publishableClientKey: newKey.publishableClientKey ?? undefined, secretServerKey: newKey.secretServerKey!, }); } finally { @@ -163,7 +165,9 @@ export default function PageClient() { ) : ( - You'll receive a publishable client key and a secret server key for this project. + {requirePublishableClientKey + ? "You'll receive a publishable client key and a secret server key for this project." + : "You'll receive a secret server key for this project."} ), }, diff --git a/apps/dashboard/src/components/data-table/api-key-table.tsx b/apps/dashboard/src/components/data-table/api-key-table.tsx index d21d30a276..fa218e5fce 100644 --- a/apps/dashboard/src/components/data-table/api-key-table.tsx +++ b/apps/dashboard/src/components/data-table/api-key-table.tsx @@ -1,6 +1,6 @@ 'use client'; -import { InternalApiKey } from '@stackframe/stack'; import { ActionCell, ActionDialog, BadgeCell, DataTable, DataTableColumnHeader, DataTableFacetedFilter, DateCell, SearchToolbarItem, TextCell, standardFilterFn } from "@/components/ui"; +import { InternalApiKey } from '@stackframe/stack'; import { ColumnDef, Row, Table } from "@tanstack/react-table"; import { useMemo, useState } from "react"; @@ -29,6 +29,17 @@ function RevokeDialog(props: { open: boolean, onOpenChange: (open: boolean) => void, }) { + const clientKeyText = props.apiKey.publishableClientKey?.lastFour + ? `client key *****${props.apiKey.publishableClientKey.lastFour}` + : null; + const serverKeyText = props.apiKey.secretServerKey?.lastFour + ? `server key *****${props.apiKey.secretServerKey.lastFour}` + : null; + const keysText = [clientKeyText, serverKeyText].filter(Boolean).join(" and "); + const confirmText = keysText + ? `Are you sure you want to revoke ${keysText}?` + : "Are you sure you want to revoke this API key?"; + return { await props.apiKey.revoke(); } }} confirmText="I understand this will unlink all the apps using this API key" > - {`Are you sure you want to revoke client key *****${props.apiKey.publishableClientKey?.lastFour} and server key *****${props.apiKey.secretServerKey?.lastFour}?`} + {confirmText} ; } @@ -59,49 +70,60 @@ function Actions({ row }: { row: Row }) { ); } -const columns: ColumnDef[] = [ - { - accessorKey: "description", - header: ({ column }) => , - cell: ({ row }) => {row.original.description}, - }, - { - accessorKey: "status", - header: ({ column }) => , - cell: ({ row }) => , - filterFn: standardFilterFn, - }, - { +const getColumns = (showPublishableClientKey: boolean): ColumnDef[] => { + const baseColumns: ColumnDef[] = [ + { + accessorKey: "description", + header: ({ column }) => , + cell: ({ row }) => {row.original.description}, + }, + { + accessorKey: "status", + header: ({ column }) => , + cell: ({ row }) => , + filterFn: standardFilterFn, + }, + ]; + const clientKeyColumn: ColumnDef = { id: "clientKey", accessorFn: (row) => row.publishableClientKey?.lastFour, header: ({ column }) => , - cell: ({ row }) => *******{row.original.publishableClientKey?.lastFour}, + cell: ({ row }) => ( + {row.original.publishableClientKey?.lastFour ? `*******${row.original.publishableClientKey.lastFour}` : "—"} + ), enableSorting: false, - }, - { + }; + const serverKeyColumn: ColumnDef = { id: "serverKey", accessorFn: (row) => row.secretServerKey?.lastFour, header: ({ column }) => , - cell: ({ row }) => *******{row.original.secretServerKey?.lastFour}, + cell: ({ row }) => {row.original.secretServerKey?.lastFour ? `*******${row.original.secretServerKey.lastFour}` : "—"}, enableSorting: false, - }, - { - accessorKey: "expiresAt", - header: ({ column }) => , - cell: ({ row }) => - }, - { - accessorKey: "createdAt", - header: ({ column }) => , - cell: ({ row }) => - }, - { - id: "actions", - cell: ({ row }) => , - }, -]; + }; + const tailColumns: ColumnDef[] = [ + { + accessorKey: "expiresAt", + header: ({ column }) => , + cell: ({ row }) => , + }, + { + accessorKey: "createdAt", + header: ({ column }) => , + cell: ({ row }) => , + }, + { + id: "actions", + cell: ({ row }) => , + }, + ]; -export function InternalApiKeyTable(props: { apiKeys: InternalApiKey[] }) { + return showPublishableClientKey + ? [...baseColumns, clientKeyColumn, serverKeyColumn, ...tailColumns] + : [...baseColumns, serverKeyColumn, ...tailColumns]; +}; + +export function InternalApiKeyTable(props: { apiKeys: InternalApiKey[], showPublishableClientKey?: boolean }) { + const showPublishableClientKey = props.showPublishableClientKey ?? true; const extendedApiKeys = useMemo(() => { const keys = props.apiKeys.map((apiKey) => ({ ...apiKey, @@ -116,6 +138,8 @@ export function InternalApiKeyTable(props: { apiKeys: InternalApiKey[] }) { }); }, [props.apiKeys]); + const columns = useMemo(() => getColumns(showPublishableClientKey), [showPublishableClientKey]); + return , + omitPublishableClientKey?: boolean, userAuth?: { accessToken?: string, refreshToken?: string, }, }): Promise { - const { body, headers, accessType, userAuth: userAuthOverride, ...otherOptions } = options ?? {}; + const { body, headers, accessType, omitPublishableClientKey, userAuth: userAuthOverride, ...otherOptions } = options ?? {}; if (typeof body === "object") { expectSnakeCase(body, "req.body"); } @@ -132,7 +135,7 @@ export async function niceBackendFetch(url: string | URL, options?: Omit { + await Project.updateConfig({ + "auth.oauth.providers.spotify": { + type: "spotify", + isShared: true, + allowSignIn: true, + allowConnectedAccounts: true, + }, + }); +}; + +const setupOAuthProject = async (requirePublishableClientKey?: boolean) => { + const { projectId } = await Project.createAndSwitch(); + if (requirePublishableClientKey !== undefined) { + await Project.updateProjectConfig({ + "project.requirePublishableClientKey": requirePublishableClientKey, + }); + } + await enableSharedSpotifyProvider(); + backendContext.set({ + projectKeys: { projectId }, + userAuth: null, + }); + return projectId; +}; it("should redirect the user to the OAuth provider with the right arguments", async ({ expect }) => { const response = await Auth.OAuth.authorize(); @@ -100,6 +126,45 @@ it("should fail if an invalid client_secret is provided", async ({ expect }) => `); }); +it("should allow public client secret sentinel when publishable keys are not required", async ({ expect }) => { + await setupOAuthProject(false); + + const response = await Auth.OAuth.authorize({ includeClientSecret: false }); + expect(response.authorizeResponse.status).toBe(307); +}); + +it("should allow public client secret sentinel when publishable keys are not configured", async ({ expect }) => { + await setupOAuthProject(); + + const response = await Auth.OAuth.authorize({ includeClientSecret: false }); + expect(response.authorizeResponse.status).toBe(307); +}); + +it("should reject public client secret sentinel when publishable keys are required", async ({ expect }) => { + await setupOAuthProject(true); + + const response = await niceBackendFetch("/api/v1/auth/oauth/authorize/spotify", { + redirect: "manual", + query: { + ...await Auth.OAuth.getAuthorizeQuery({ includeClientSecret: false }), + }, + }); + expect(response).toMatchInlineSnapshot(` + NiceResponse { + "status": 401, + "body": { + "code": "PUBLISHABLE_CLIENT_KEY_REQUIRED_FOR_PROJECT", + "details": { "project_id": "" }, + "error": "Publishable client keys are required for this project. Create one in Project Keys, or disable this requirement there to allow keyless client access.", + }, + "headers": Headers { + "x-stack-known-error": "PUBLISHABLE_CLIENT_KEY_REQUIRED_FOR_PROJECT", +