Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
-- Create temporary index to speed up the migration
-- 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" #>> '{project,requirePublishableClientKey}') IS NULL
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";
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<ReturnType<typeof providerObj.getCallback>>;
try {
Expand Down
25 changes: 24 additions & 1 deletion apps/backend/src/app/api/latest/auth/oauth/token/route.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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({
Expand All @@ -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);
}
}
}
Comment thread
N2D4 marked this conversation as resolved.

Comment thread
cursor[bot] marked this conversation as resolved.
const oauthRequest = new OAuthRequest({
headers: {
...fullReq.headers,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
import { getBranchConfigOverrideQuery, getEnvironmentConfigOverrideQuery, overrideBranchConfigOverride, overrideEnvironmentConfigOverride, setBranchConfigOverride, setBranchConfigOverrideSource, setEnvironmentConfigOverride } from "@/lib/config";
import {
getBranchConfigOverrideQuery,
getEnvironmentConfigOverrideQuery,
getProjectConfigOverrideQuery,
overrideBranchConfigOverride,
overrideEnvironmentConfigOverride,
overrideProjectConfigOverride,
setBranchConfigOverride,
setBranchConfigOverrideSource,
setEnvironmentConfigOverride,
setProjectConfigOverride,
} 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 } from "@stackframe/stack-shared/dist/utils/errors";
import * as yup from "yup";

type BranchConfigSourceApi = yup.InferType<typeof branchConfigSourceSchema>;

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;
Expand All @@ -25,6 +36,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,
},
Comment thread
N2D4 marked this conversation as resolved.
branch: {
schema: branchConfigSchema,
migrate: (config: any) => migrateConfigOverride("branch", config),
Expand Down Expand Up @@ -120,7 +149,7 @@ const writeResponseSchema = yupObject({

async function parseAndValidateConfig(
configString: string,
levelConfig: typeof levelConfigs["branch" | "environment"]
levelConfig: typeof levelConfigs["branch" | "environment" | "project"]
Comment thread
N2D4 marked this conversation as resolved.
) {
let parsedConfig;
try {
Expand Down
55 changes: 31 additions & 24 deletions apps/backend/src/lib/internal-api-keys.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<Promise<Result<void, CheckApiKeySetError>>> {
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}
`;
Expand All @@ -34,35 +59,17 @@ export function checkApiKeySetQuery(projectId: string, key: KeyType): RawQuery<b
AND "manuallyRevokedAt" IS NULL
AND "expiresAt" > ${new Date()}
`,
postProcess: (rows) => rows[0]?.result === "t",
postProcess: async (rows): Promise<Result<void, CheckApiKeySetError>> =>
rows[0]?.result === "t" ? Result.ok(undefined) : Result.error("invalid-key"),
};
}

export async function checkApiKeySet(projectId: string, key: KeyType): Promise<boolean> {
export async function checkApiKeySet(projectId: string, key: KeyType): Promise<Result<void, CheckApiKeySetError>> {
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<boolean> {
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 }
Expand Down
5 changes: 4 additions & 1 deletion apps/backend/src/oauth/model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
24 changes: 19 additions & 5 deletions apps/backend/src/route-handlers/smart-request.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
N2D4 marked this conversation as resolved.
Comment thread
N2D4 marked this conversation as resolved.

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: {
Expand Down
Loading
Loading