Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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,16 +1,45 @@
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 { 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();

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 @@ -106,7 +135,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
24 changes: 21 additions & 3 deletions apps/backend/src/lib/internal-api-keys.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,36 @@
// 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 { StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors';
import { generateUuid } from '@stackframe/stack-shared/dist/utils/uuids';
import { publishableClientKeyNotNecessarySentinel } from '@stackframe/stack-shared/dist/utils/oauth';
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 function checkApiKeySetQuery(projectId: string, key: KeyType): RawQuery<Promise<boolean>> {
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;
return !config.project.requirePublishableClientKey;
},
);
}

const whereClause = Prisma.sql`
${Prisma.raw(JSON.stringify(keyType))} = ${keyValue}
`;
Expand All @@ -34,7 +46,7 @@ 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) => rows[0]?.result === "t",
};
}

Expand All @@ -56,6 +68,12 @@ export async function checkApiKeySet(projectId: string, key: KeyType): Promise<b
}

async function checkApiKeySetLegacy(projectId: string, key: KeyType): Promise<boolean> {
const validatedKey = validateKeyType(key);
if ("publishableClientKey" in validatedKey && validatedKey.publishableClientKey === publishableClientKeyNotNecessarySentinel) {
const config = await rawQuery(globalPrismaClient, getRenderedProjectConfigQuery({ projectId }));
return !config.project.requirePublishableClientKey;
}

const set = await getApiKeySet(projectId, key);
if (!set) return false;
if (set.manually_revoked_at_millis) return false;
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/lib/tokens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const accessTokenSchema = yupObject({

export const oauthCookieSchema = yupObject({
tenancyId: yupString().defined(),
publishableClientKey: yupString().defined(),
publishableClientKey: yupString().optional(),
innerCodeVerifier: yupString().defined(),
redirectUri: yupString().defined(),
scope: yupString().defined(),
Expand Down
17 changes: 13 additions & 4 deletions apps/backend/src/route-handlers/smart-request.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,10 @@ 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
Expand All @@ -272,18 +276,23 @@ const parseAuth = withTraceSpan('smart request parseAuth', async (req: NextReque
} 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) throw new KnownErrors.InvalidPublishableClientKey(projectId);
break;
}
case "server": {
if (!secretServerKey) throw new KnownErrors.ServerAuthenticationRequired();
if (!queriesResults.isServerKeyValid) throw new KnownErrors.InvalidSecretServerKey(projectId);
if (!isServerKeyValid) throw new KnownErrors.InvalidSecretServerKey(projectId);
break;
}
case "admin": {
if (!superSecretAdminKey) throw new KnownErrors.AdminAuthenticationRequired();
if (!queriesResults.isAdminKeyValid) throw new KnownErrors.InvalidSuperSecretAdminKey(projectId);
if (!isAdminKeyValid) throw new KnownErrors.InvalidSuperSecretAdminKey(projectId);
break;
}
default: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,15 @@ 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 publishableClientKeyLine = (line: string) => requirePublishableClientKey ? line : "";
const publishableClientKeyValue = keys?.publishableClientKey ?? "...";

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),
Expand All @@ -40,7 +44,7 @@ export default function SetupPage(props: { toMetrics: () => void }) {

setKeys({
projectId: adminApp.projectId,
publishableClientKey: newKey.publishableClientKey!,
publishableClientKey: newKey.publishableClientKey ?? undefined,
secretServerKey: newKey.secretServerKey!,
});
};
Expand Down Expand Up @@ -129,8 +133,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 ?? "..."}",
tokenStore: "cookie",
${publishableClientKeyLine(` publishableClientKey: "${publishableClientKeyValue}",\n`)} tokenStore: "cookie",
Comment thread
N2D4 marked this conversation as resolved.
Outdated
redirectMethod: {
useNavigate,
}
Expand Down Expand Up @@ -245,8 +248,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 ?? "..."}",
secretServerKey: "${keys?.secretServerKey ?? "..."}",
${publishableClientKeyLine(` publishableClientKey: "${publishableClientKeyValue}",\n`)} secretServerKey: "${keys?.secretServerKey ?? "..."}",
tokenStore: "memory",
});
`}
Expand All @@ -263,8 +265,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",
tokenStore: "cookie",
${publishableClientKeyLine(` publishableClientKey: "your-publishable-client-key",\n`)} tokenStore: "cookie",
});
`}
title="stack/client.ts"
Expand Down Expand Up @@ -379,8 +380,7 @@ export default function SetupPage(props: { toMetrics: () => void }) {
'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-secret-server-key': "${keys?.secretServerKey ?? "..."}",
${publishableClientKeyLine(` 'x-stack-publishable-client-key': "${publishableClientKeyValue}",\n`)} 'x-stack-secret-server-key': "${keys?.secretServerKey ?? "..."}",
**kwargs.pop('headers', {}),
},
**kwargs,
Expand Down Expand Up @@ -606,7 +606,7 @@ function GlobeIllustrationInner() {
}

function StackAuthKeys(props: {
keys: { projectId: string, publishableClientKey: string, secretServerKey: string } | null,
keys: { projectId: string, publishableClientKey?: string, secretServerKey: string } | null,
onGenerateKeys: () => Promise<void>,
type: 'next' | 'raw',
}) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -31,12 +34,27 @@ export default function PageClient() {
</Button>
}
>
<InternalApiKeyTable apiKeys={apiKeySets} />
<InternalApiKeyTable
apiKeys={apiKeySets}
showPublishableClientKey={requirePublishableClientKey}
/>

<SettingSwitch
label="[Advanced] Require publishable client keys"
hint="When enabled, client requests must include a publishable client key."
checked={requirePublishableClientKey}
onCheckedChange={async (checked) => {
await project.update({
requirePublishableClientKey: checked,
});
}}
/>

<CreateDialog
open={isNewApiKeyDialogOpen}
onOpenChange={setIsNewApiKeyDialogOpen}
onKeyCreated={setReturnedApiKey}
requirePublishableClientKey={requirePublishableClientKey}
/>
<ShowKeyDialog
apiKey={returnedApiKey || undefined}
Expand All @@ -61,6 +79,7 @@ function CreateDialog(props: {
open: boolean,
onOpenChange: (open: boolean) => void,
onKeyCreated?: (key: InternalApiKeyFirstView) => void,
requirePublishableClientKey: boolean,
}) {
const stackAdminApp = useAdminApp();
const params = useSearchParams();
Expand All @@ -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),
Expand Down
Loading
Loading