Skip to content

Commit 73fdd5f

Browse files
thomasballingerConvex, Inc.
authored andcommitted
WorkOS integration tweaks (#41252)
GitOrigin-RevId: f8d673951d617e5d05bdeda7cac796d07b67c8fe
1 parent 22b850a commit 73fdd5f

File tree

5 files changed

+179
-44
lines changed

5 files changed

+179
-44
lines changed

src/cli/integration.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,11 @@ const workosProvisionEnvironment = new Command("provision-environment")
113113
ctx,
114114
deployment.deploymentName,
115115
deployment,
116+
{
117+
offerToAssociateWorkOSTeam: true,
118+
autoProvisionIfWorkOSTeamAssociated: true,
119+
autoConfigureAuthkitConfig: true,
120+
},
116121
);
117122
} catch (error) {
118123
await ctx.crash({

src/cli/lib/config.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
deprecationCheckWarning,
3030
logAndHandleFetchError,
3131
ThrowingFetchError,
32+
currentPackageHomepage,
3233
} from "./utils/utils.js";
3334
import { createHash } from "crypto";
3435
import { promisify } from "util";
@@ -1110,10 +1111,34 @@ export async function handlePushConfigError(
11101111
// deployments may be able to supply it by provisioning a fresh WorkOS
11111112
// environment on demand.
11121113
if (variableName === "WORKOS_CLIENT_ID" && deploymentName && deployment) {
1114+
// Initially only specific templates create WorkOS environments on demand
1115+
// because the local environemnt variables are hardcoded for Vite and Next.js.
1116+
const homepage = await currentPackageHomepage(ctx);
1117+
const autoProvisionIfWorkOSTeamAssociated = !!(
1118+
homepage &&
1119+
[
1120+
"https://github.com/workos/template-convex-nextjs-authkit/#readme",
1121+
"https://github.com/workos/template-convex-react-vite-authkit/#readme",
1122+
"https://github.com:workos/template-convex-react-vite-authkit/#readme",
1123+
].includes(homepage)
1124+
);
1125+
// Initially only specific templates offer team creation.
1126+
// Until this changes it can be done manually with a CLI command.
1127+
const offerToAssociateWorkOSTeam = autoProvisionIfWorkOSTeamAssociated;
1128+
// Initialy only specific template auto-configure WorkOS environments
1129+
// with AuthKit config because these values are currently heuristics.
1130+
// This will be some more explicit opt-in in the future.
1131+
const autoConfigureAuthkitConfig = autoProvisionIfWorkOSTeamAssociated;
1132+
11131133
const result = await ensureWorkosEnvironmentProvisioned(
11141134
ctx,
11151135
deploymentName,
11161136
deployment,
1137+
{
1138+
offerToAssociateWorkOSTeam,
1139+
autoProvisionIfWorkOSTeamAssociated,
1140+
autoConfigureAuthkitConfig,
1141+
},
11171142
);
11181143
if (result === "ready") {
11191144
return await ctx.crash({

src/cli/lib/envvars.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export async function suggestedEnvVarName(ctx: Context): Promise<{
9898
detectedFramework?: Framework;
9999
envVar: string;
100100
frontendDevUrl?: string;
101+
publicPrefix?: string;
101102
}> {
102103
// no package.json, that's fine, just guess
103104
if (!ctx.fs.exists("package.json")) {
@@ -115,6 +116,7 @@ export async function suggestedEnvVarName(ctx: Context): Promise<{
115116
detectedFramework: "create-react-app",
116117
envVar: "REACT_APP_CONVEX_URL",
117118
frontendDevUrl: "http://localhost:3000",
119+
publicPrefix: "REACT_APP_",
118120
};
119121
}
120122

@@ -124,6 +126,7 @@ export async function suggestedEnvVarName(ctx: Context): Promise<{
124126
detectedFramework: "Next.js",
125127
envVar: "NEXT_PUBLIC_CONVEX_URL",
126128
frontendDevUrl: "http://localhost:3000",
129+
publicPrefix: "NEXT_PUBLIC_",
127130
};
128131
}
129132

@@ -132,6 +135,7 @@ export async function suggestedEnvVarName(ctx: Context): Promise<{
132135
return {
133136
detectedFramework: "Expo",
134137
envVar: "EXPO_PUBLIC_CONVEX_URL",
138+
publicPrefix: "EXPO_PUBLIC_",
135139
};
136140
}
137141

@@ -150,6 +154,7 @@ export async function suggestedEnvVarName(ctx: Context): Promise<{
150154
detectedFramework: "SvelteKit",
151155
envVar: "PUBLIC_CONVEX_URL",
152156
frontendDevUrl: "http://localhost:5173",
157+
publicPrefix: "PUBLIC_",
153158
};
154159
}
155160

@@ -161,6 +166,7 @@ export async function suggestedEnvVarName(ctx: Context): Promise<{
161166
detectedFramework: "Vite",
162167
envVar: "VITE_CONVEX_URL",
163168
frontendDevUrl: "http://localhost:5173",
169+
publicPrefix: "VITE_",
164170
};
165171
}
166172

src/cli/lib/utils/utils.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1181,3 +1181,27 @@ export function isWebContainer(): boolean {
11811181
} catch {}
11821182
return blitzInternalEnv !== null && blitzInternalEnv !== undefined;
11831183
}
1184+
1185+
// For (rare) special behaviors based on package.json details.
1186+
export async function currentPackageHomepage(
1187+
ctx: Context,
1188+
): Promise<string | null> {
1189+
const { parentPackageJson: packageJsonPath } = await findParentConfigs(ctx);
1190+
let packageJson: any;
1191+
try {
1192+
const packageJsonString = ctx.fs.readUtf8File(packageJsonPath);
1193+
packageJson = JSON.parse(packageJsonString);
1194+
} catch (error: any) {
1195+
return await ctx.crash({
1196+
exitCode: 1,
1197+
errorType: "invalid filesystem data",
1198+
printedMessage: `Couldn't parse "${packageJsonPath}". Make sure it's a valid JSON. Error: ${error}`,
1199+
});
1200+
}
1201+
const name = packageJson["homepage"];
1202+
if (typeof name !== "string") {
1203+
// wrong type or missing
1204+
return null;
1205+
}
1206+
return name;
1207+
}

src/cli/lib/workos/workos.ts

Lines changed: 119 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,17 @@
77
* This flow may be kicked off by discovering that WORKOS_CLIENT_ID
88
* is required in a convex/auth.config.ts but not present on the deployment.
99
*/
10+
import crypto from "crypto";
1011
import { Context } from "../../../bundler/context.js";
1112
import {
1213
changeSpinner,
13-
logError,
1414
logFinishedStep,
1515
logMessage,
16+
logWarning,
1617
showSpinner,
1718
stopSpinner,
1819
} from "../../../bundler/log.js";
1920
import { getTeamAndProjectSlugForDeployment } from "../api.js";
20-
import { deploymentDashboardUrlPage } from "../dashboard.js";
2121
import { callUpdateEnvironmentVariables, envGetInDeployment } from "../env.js";
2222
import { changedEnvVarFile, suggestedEnvVarName } from "../envvars.js";
2323
import { promptOptions, promptYesNo } from "../utils/prompts.js";
@@ -45,7 +45,16 @@ export async function ensureWorkosEnvironmentProvisioned(
4545
adminKey: string;
4646
deploymentNotice: string;
4747
},
48+
options: {
49+
offerToAssociateWorkOSTeam: boolean;
50+
autoProvisionIfWorkOSTeamAssociated: boolean;
51+
autoConfigureAuthkitConfig: boolean;
52+
},
4853
): Promise<"ready" | "choseNotToAssociatedTeam"> {
54+
if (!options.autoConfigureAuthkitConfig) {
55+
return "choseNotToAssociatedTeam";
56+
}
57+
4958
showSpinner("Checking for associated AuthKit environment...");
5059
const existingEnvVars = await getExistingWorkosEnvVars(ctx, deployment);
5160
if (
@@ -56,7 +65,12 @@ export async function ensureWorkosEnvironmentProvisioned(
5665
logFinishedStep(
5766
"Deployment has a WorkOS environment configured for AuthKit.",
5867
);
59-
await updateEnvLocal(ctx, existingEnvVars.clientId);
68+
await updateEnvLocal(
69+
ctx,
70+
existingEnvVars.clientId,
71+
existingEnvVars.apiKey,
72+
existingEnvVars.environmentId,
73+
);
6074
await updateWorkosEnvironment(ctx, existingEnvVars.apiKey);
6175
return "ready";
6276
}
@@ -65,11 +79,14 @@ export async function ensureWorkosEnvironmentProvisioned(
6579
const { hasAssociatedWorkosTeam, teamId, disabled } =
6680
await getDeploymentCanProvisionWorkOSEnvironments(ctx, deploymentName);
6781

82+
// In case this this becomes a legacy flow that no longer works.
6883
if (disabled) {
6984
return "choseNotToAssociatedTeam";
7085
}
71-
7286
if (!hasAssociatedWorkosTeam) {
87+
if (!options.offerToAssociateWorkOSTeam) {
88+
return "choseNotToAssociatedTeam";
89+
}
7390
const result = await tryToCreateAssociatedWorkosTeam(
7491
ctx,
7592
deploymentName,
@@ -117,7 +134,7 @@ export async function ensureWorkosEnvironmentProvisioned(
117134
data.apiKey,
118135
);
119136
showSpinner("Updating .env.local with WorkOS configuration");
120-
await updateEnvLocal(ctx, data.clientId);
137+
await updateEnvLocal(ctx, data.clientId, data.apiKey, data.environmentId);
121138

122139
await updateWorkosEnvironment(ctx, data.apiKey);
123140

@@ -141,21 +158,15 @@ export async function tryToCreateAssociatedWorkosTeam(
141158
}
142159
stopSpinner();
143160

144-
const variableName = "WORKOS_CLIENT_ID";
145-
const variableQuery =
146-
variableName !== undefined ? `?var=${variableName}` : "";
147-
const dashboardUrl = deploymentDashboardUrlPage(
148-
deploymentName,
149-
`/settings/environment-variables${variableQuery}`,
150-
);
151-
152161
const agree = await promptYesNo(ctx, {
153-
prefix: `A WorkOS team can be created for your Convex team "${teamInfo.teamSlug}" to use for AuthKit.
162+
prefix: `A WorkOS team needs to be created for your Convex team "${teamInfo.teamSlug}" in order to use AuthKit.
163+
164+
You and other members of this team will be able to create WorkOS environments for each Convex dev deployment for projects in this team.
154165
155-
You and other members of this team will be able to create a WorkOS environments for each Convex deployments for projects on this team.
156166
By creating this account you agree to the WorkOS Terms of Service (https://workos.com/legal/terms-of-service) and Privacy Policy (https://workos.com/legal/privacy).
157-
To provide your own WorkOS environment credentials instead, choose no and set environment variables manually on the dashboard, e.g. \n${dashboardUrl}\n\n`,
158-
message: `Create WorkOS team and enable automatic AuthKit environment provisioning for team "${teamInfo.teamSlug}"?`,
167+
Alternately, choose no and set WORKOS_CLIENT_ID for an existing WorkOS environment.
168+
\n`,
169+
message: `Create a WorkOS team and enable automatic AuthKit environment provisioning for team "${teamInfo.teamSlug}"?`,
159170
});
160171
if (!agree) {
161172
return "choseNotToAssociatedTeam";
@@ -182,7 +193,7 @@ To provide your own WorkOS environment credentials instead, choose no and set en
182193
: "\nTo use another email address visit https://dashboard.convex.dev/profile to add and verify, then choose 'refresh'",
183194
choices: [
184195
...availableEmails.map((email) => ({
185-
name: `${email}${alreadyTried.has(email) ? ` (can't create new, already has a WorkOS account)` : ""}`,
196+
name: `${email}${alreadyTried.has(email) ? ` (can't create, a WorkOS team already exists with this email)` : ""}`,
186197
value: email,
187198
})),
188199
{
@@ -267,39 +278,98 @@ async function applyConfigToWorkosEnvironment(
267278
}
268279
}
269280

270-
async function updateEnvLocal(ctx: Context, clientId: string) {
281+
// Given a WORKOS_CLIENT_ID try to configure the .env.local appropriately
282+
// for a framework. This flow supports only Vite and Next.js for now.
283+
async function updateEnvLocal(
284+
ctx: Context,
285+
clientId: string,
286+
apiKey: string,
287+
environmentId: string,
288+
) {
271289
const envPath = ".env.local";
272290

273-
try {
274-
const existingContent = ctx.fs.exists(envPath)
275-
? ctx.fs.readUtf8File(envPath)
276-
: null;
277-
278-
const clientIdUpdate = changedEnvVarFile({
279-
existingFileContent: existingContent,
280-
envVarName: "VITE_WORKOS_CLIENT_ID",
281-
envVarValue: clientId,
282-
commentAfterValue: null,
283-
commentOnPreviousLine: null,
284-
});
291+
const { frontendDevUrl, detectedFramework, publicPrefix } =
292+
await suggestedEnvVarName(ctx);
293+
294+
// For now don't attempt for anything other than Vite or Next.js.
295+
if (!detectedFramework || !["Vite", "Next.js"].includes(detectedFramework)) {
296+
logWarning(
297+
"Can't configure .env.local, fill it out according to directions for the corresponding AuthKit SDK. Use `npx convex list` to see relevant environment variables.",
298+
);
299+
}
285300

286-
if (clientIdUpdate !== null) {
287-
ctx.fs.writeUtf8File(envPath, clientIdUpdate);
301+
let suggestedChanges: Record<
302+
string,
303+
{
304+
value: string;
305+
commentAfterValue?: string;
306+
commentOnPreviousLine?: string;
307+
}
308+
> = {};
309+
310+
let existingFileContent = ctx.fs.exists(envPath)
311+
? ctx.fs.readUtf8File(envPath)
312+
: null;
313+
314+
if (publicPrefix) {
315+
if (detectedFramework === "Vite") {
316+
suggestedChanges[`${publicPrefix}WORKOS_CLIENT_ID`] = {
317+
value: clientId,
318+
commentOnPreviousLine: `# See this environment at ${workosUrl(environmentId, "/authentication")}`,
319+
};
320+
} else if (detectedFramework === "Next.js") {
321+
// Next doesn't need the clint id to be public
322+
suggestedChanges[`WORKOS_CLIENT_ID`] = {
323+
value: clientId,
324+
commentOnPreviousLine: `# See this environment at ${workosUrl(environmentId, "/authentication")}`,
325+
};
288326
}
289327

290-
const redirectUriUpdate = changedEnvVarFile({
291-
existingFileContent: clientIdUpdate || existingContent,
292-
envVarName: "VITE_WORKOS_REDIRECT_URI",
293-
envVarValue: "http://localhost:5173/callback",
294-
commentAfterValue: null,
295-
commentOnPreviousLine: null,
296-
});
328+
if (frontendDevUrl) {
329+
suggestedChanges[`${publicPrefix}WORKOS_REDIRECT_URI`] = {
330+
value: `${frontendDevUrl}/callback`,
331+
};
332+
}
333+
}
297334

298-
if (redirectUriUpdate !== null) {
299-
ctx.fs.writeUtf8File(envPath, redirectUriUpdate);
335+
if (detectedFramework === "Next.js") {
336+
if (
337+
!existingFileContent ||
338+
!existingFileContent.includes("WORKOS_COOKIE_PASSWORD")
339+
) {
340+
suggestedChanges["WORKOS_COOKIE_PASSWORD"] = {
341+
value: crypto.randomBytes(32).toString("base64url"),
342+
};
300343
}
301-
} catch (error) {
302-
logError(`Could not update .env.local: ${String(error)}`);
344+
suggestedChanges["WORKOS_API_KEY"] = { value: apiKey };
345+
}
346+
347+
for (const [
348+
envVarName,
349+
{ value: envVarValue, commentOnPreviousLine, commentAfterValue },
350+
] of Object.entries(suggestedChanges) as [
351+
string,
352+
{
353+
value: string;
354+
commentOnPreviousLine?: string;
355+
commentAfterValue?: string;
356+
},
357+
][]) {
358+
existingFileContent =
359+
changedEnvVarFile({
360+
existingFileContent,
361+
envVarName,
362+
envVarValue,
363+
commentAfterValue: commentAfterValue ?? null,
364+
commentOnPreviousLine: commentOnPreviousLine ?? null,
365+
}) || existingFileContent;
366+
}
367+
368+
if (existingFileContent !== null) {
369+
ctx.fs.writeUtf8File(envPath, existingFileContent);
370+
logMessage(
371+
`Updated .env.local with ${Object.keys(suggestedChanges).join(", ")}`,
372+
);
303373
}
304374
}
305375

@@ -340,3 +410,8 @@ async function setConvexEnvVars(
340410
{ name: "WORKOS_ENVIRONMENT_API_KEY", value: workosEnvironmentApiKey },
341411
]);
342412
}
413+
414+
type Subpaths = "/authentication" | "/sessions" | "/redirects" | "/users";
415+
function workosUrl(environmentId: string, subpath: Subpaths) {
416+
return `https://dashboard.workos.com/${environmentId}${subpath}`;
417+
}

0 commit comments

Comments
 (0)