From e0c3c873bd09ddeeb095b92403d5be64fe6129d6 Mon Sep 17 00:00:00 2001 From: Jason Barry Date: Thu, 24 Sep 2026 16:01:00 -0700 Subject: [PATCH 01/34] fix(install): register redirect URIs for every server-side SDK The installer machine only called autoConfigureWorkOSEnvironment for TanStack Start and React Router, and returned early for non-JavaScript SDKs. Each integration's run() skips its own call when the machine passes credentials, so SvelteKit, Node, PHP, Laravel, Python, Ruby, Go, .NET, Kotlin and Elixir never registered their redirect URI, CORS origin or homepage URL. Key the check on the integration's requiresApiKey flag (Next.js keeps its post-validation path), and register URLs before the non-JavaScript return. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018EgeNr2FXXVcJ5o165U5Lx --- src/lib/run-with-core.setup.spec.ts | 41 +++++++++++++++++++++++++++++ src/lib/run-with-core.ts | 19 ++++++++----- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/src/lib/run-with-core.setup.spec.ts b/src/lib/run-with-core.setup.spec.ts index 3be91fc8..7abd245c 100644 --- a/src/lib/run-with-core.setup.spec.ts +++ b/src/lib/run-with-core.setup.spec.ts @@ -134,6 +134,47 @@ describe('dashboard checklist reporting', () => { ]); }); + const registeredPaths = () => fetchSpy.mock.calls.map(([url]) => new URL(String(url)).pathname); + + it('registers the redirect URI and CORS origin for every server-side SDK', async () => { + const { emitter, events } = record(); + await configureInstallEnvironment({ + options, + integration: 'sveltekit', + credentials: { apiKey: 'sk_test_a', clientId: 'client_a' }, + emitter, + }); + expect(registeredPaths()).toEqual( + expect.arrayContaining(['/user_management/redirect_uris', '/user_management/cors_origins']), + ); + expect(events).toContain('config:step redirect-uri done'); + expect(events).toContain('config:step env-vars done'); + }); + + it('registers the URLs for a non-JavaScript SDK without writing .env.local', async () => { + const { emitter, events } = record(); + await configureInstallEnvironment({ + options, + integration: 'ruby', + credentials: { apiKey: 'sk_test_a', clientId: 'client_a' }, + emitter, + }); + const redirect = fetchSpy.mock.calls.find(([url]) => String(url).endsWith('/user_management/redirect_uris')); + expect(JSON.parse(String(redirect?.[1]?.body))).toEqual({ uri: 'http://localhost:3000/auth/callback' }); + expect(events).toContain('config:step redirect-uri done'); + expect(events.some((event) => event.includes('env-vars'))).toBe(false); + await expect(readFile(join(directory, '.env.local'))).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('registers nothing for a client-only SDK', async () => { + await configureInstallEnvironment({ + options, + integration: 'react', + credentials: { apiKey: 'sk_test_a', clientId: 'client_a' }, + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + it('says why the redirect URI and CORS origin were not set without an API key', async () => { const { emitter, events } = record(); await configureInstallEnvironment({ diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index d7dafdb4..046f0a93 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -200,7 +200,8 @@ export async function configureInstallEnvironment( const registry = await getRegistry(); const mod = registry.get(integration); - if (mod?.config.metadata.language !== 'javascript') return; + if (!mod) return; + const isJavascript = mod.config.metadata.language === 'javascript'; if (integration === 'nextjs') { assertSupportedNextJsRouter(await getNextJsRouter(installerOptions)); @@ -208,23 +209,29 @@ export async function configureInstallEnvironment( } const port = detectPort(integration, installerOptions.installDir); - const redirectUri = installerOptions.redirectUri || `http://localhost:${port}${getCallbackPath(integration)}`; // Next.js URL writes happen after code validation. That step chooses ONE // target: the dashboard application, or an API-key-only callback without a session. - const requiresApiKey = ['tanstack-start', 'react-router'].includes(integration); - step('env-vars', 'started'); - if (credentials.apiKey && requiresApiKey) { + // Every other server-side SDK registers its URLs here: the integration's own + // run() skips it because this machine passes it the credentials. + const registersUrls = mod.config.environment.requiresApiKey && integration !== 'nextjs'; + if (isJavascript) step('env-vars', 'started'); + if (credentials.apiKey && registersUrls) { await autoConfigureWorkOSEnvironment(credentials.apiKey, integration, port, { homepageUrl: installerOptions.homepageUrl, redirectUri: installerOptions.redirectUri, onStep: step, }); - } else if (requiresApiKey) { + } else if (registersUrls) { for (const id of ['redirect-uri', 'cors-origin'] as const) { step(id, 'skipped', 'No API key was available for this install.'); } } + // Non-JavaScript agents write their own env files in the project's format. + if (!isJavascript) return; + + const redirectUri = installerOptions.redirectUri || `http://localhost:${port}${getCallbackPath(integration)}`; + const redirectUriKey = integration === 'nextjs' ? 'NEXT_PUBLIC_WORKOS_REDIRECT_URI' : 'WORKOS_REDIRECT_URI'; try { writeEnvLocal(installerOptions.installDir, { From 78f19230c957a38c3ae23444796141acaa741485 Mon Sep 17 00:00:00 2001 From: Jason Barry Date: Thu, 24 Sep 2026 16:04:01 -0700 Subject: [PATCH 02/34] fix(install): register redirect URIs and CORS origins for client-only SDKs React and vanilla JS (Vite) apps declare requiresApiKey: false, so the previous fix still skipped them, though the install usually holds an API key from the staging credentials. A client-only app needs its callback and, above all, its CORS origin registered. Register URLs for every SDK except Next.js whenever an API key is available. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018EgeNr2FXXVcJ5o165U5Lx --- src/lib/run-with-core.setup.spec.ts | 22 ++++++++++++++++++++-- src/lib/run-with-core.ts | 6 +++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/lib/run-with-core.setup.spec.ts b/src/lib/run-with-core.setup.spec.ts index 7abd245c..f571e964 100644 --- a/src/lib/run-with-core.setup.spec.ts +++ b/src/lib/run-with-core.setup.spec.ts @@ -166,12 +166,30 @@ describe('dashboard checklist reporting', () => { await expect(readFile(join(directory, '.env.local'))).rejects.toMatchObject({ code: 'ENOENT' }); }); - it('registers nothing for a client-only SDK', async () => { + it.each(['react', 'vanilla-js'] as const)( + 'registers the Vite callback and CORS origin for the client-only %s SDK', + async (integration) => { + await configureInstallEnvironment({ + options, + integration, + credentials: { apiKey: 'sk_test_a', clientId: 'client_a' }, + }); + const bodyFor = (path: string) => + JSON.parse(String(fetchSpy.mock.calls.find(([url]) => String(url).endsWith(path))?.[1]?.body)); + expect(bodyFor('/user_management/redirect_uris')).toEqual({ uri: 'http://localhost:5173/callback' }); + expect(bodyFor('/user_management/cors_origins')).toEqual({ origin: 'http://localhost:5173' }); + }, + ); + + it('says why a client-only SDK without an API key has no URLs registered', async () => { + const { emitter, events } = record(); await configureInstallEnvironment({ options, integration: 'react', - credentials: { apiKey: 'sk_test_a', clientId: 'client_a' }, + credentials: { clientId: 'client_a' }, + emitter, }); + expect(events).toContain('config:step redirect-uri skipped (No API key was available for this install.)'); expect(fetchSpy).not.toHaveBeenCalled(); }); diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index 046f0a93..57a2cd27 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -211,9 +211,9 @@ export async function configureInstallEnvironment( const port = detectPort(integration, installerOptions.installDir); // Next.js URL writes happen after code validation. That step chooses ONE // target: the dashboard application, or an API-key-only callback without a session. - // Every other server-side SDK registers its URLs here: the integration's own - // run() skips it because this machine passes it the credentials. - const registersUrls = mod.config.environment.requiresApiKey && integration !== 'nextjs'; + // Every other SDK registers its URLs here, client-only ones included: the + // integration's own run() skips it because this machine passes it the credentials. + const registersUrls = integration !== 'nextjs'; if (isJavascript) step('env-vars', 'started'); if (credentials.apiKey && registersUrls) { await autoConfigureWorkOSEnvironment(credentials.apiKey, integration, port, { From 8b78cbd5986273bec5d4cc7e787e97d7b3f76dcd Mon Sep 17 00:00:00 2001 From: Jason Barry Date: Thu, 24 Sep 2026 16:19:15 -0700 Subject: [PATCH 03/34] feat(install): save sign-out and initiate login URIs for every SDK Only Next.js set the Sign-out URI and Initiate login URI, through the dashboard GraphQL path. The REST API has no endpoint for either, so other SDKs left both for the user to set by hand. - Run configureAuthkitApplication after the agent for every SDK. For SDKs other than Next.js, a failure leaves the settings for the dashboard instead of failing a finished install. - Save the origin as the Sign-out URI everywhere. Save an Initiate login URI only where the SDK guide fixes the route (signInPath in cli.config.ts); otherwise report it as not set. - React and vanilla JS apps get a /sign-in client route that calls signIn() on load, checked by validation before it is saved. - Pin the agent to that exact route in the prompt. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018EgeNr2FXXVcJ5o165U5Lx --- src/cli.config.ts | 10 ++ src/lib/agent-runner.spec.ts | 23 +++++ src/lib/agent-runner.ts | 25 ++++- src/lib/authkit-application-setup.spec.ts | 43 ++++++++- src/lib/authkit-application-setup.ts | 35 +++++-- src/lib/completion-data.ts | 4 +- src/lib/port-detection.ts | 5 + src/lib/run-with-core.setup.spec.ts | 111 +++++++++++++++++++++- src/lib/run-with-core.ts | 69 ++++++++++++-- src/lib/settings.ts | 2 + src/lib/validation/validator.spec.ts | 41 ++++++++ src/lib/validation/validator.ts | 39 +++++++- 12 files changed, 385 insertions(+), 22 deletions(-) diff --git a/src/cli.config.ts b/src/cli.config.ts index c40f00e0..d6df1f16 100644 --- a/src/cli.config.ts +++ b/src/cli.config.ts @@ -39,10 +39,12 @@ export const config = { nextjs: { port: 3000, callbackPath: '/auth/callback', + signInPath: '/sign-in', }, react: { port: 5173, callbackPath: '/callback', + signInPath: '/sign-in', }, tanstackStart: { port: 3000, @@ -55,14 +57,17 @@ export const config = { vanillaJs: { port: 5173, callbackPath: '/callback', + signInPath: '/sign-in', }, python: { port: 8000, callbackPath: '/auth/callback/', + signInPath: '/auth/login/', }, ruby: { port: 3000, callbackPath: '/auth/callback', + signInPath: '/auth/login', }, php: { port: 8000, @@ -71,22 +76,27 @@ export const config = { phpLaravel: { port: 8000, callbackPath: '/auth/callback', + signInPath: '/login', }, go: { port: 8080, callbackPath: '/auth/callback', + signInPath: '/auth/login', }, dotnet: { port: 5000, callbackPath: '/auth/callback', + signInPath: '/auth/login', }, elixir: { port: 4000, callbackPath: '/auth/callback', + signInPath: '/auth/sign-in', }, kotlin: { port: 8080, callbackPath: '/auth/callback', + signInPath: '/auth/login', }, }, diff --git a/src/lib/agent-runner.spec.ts b/src/lib/agent-runner.spec.ts index 27f1fa06..584d1701 100644 --- a/src/lib/agent-runner.spec.ts +++ b/src/lib/agent-runner.spec.ts @@ -107,6 +107,29 @@ describe('installer prompt', () => { } }); + const promptFor = async (integration: FrameworkConfig['metadata']['integration'], skillName: string) => { + await runAgentInstaller({ ...config, metadata: { ...config.metadata, integration, skillName } }, options); + return vi.mocked(runAgent).mock.calls[0][1]; + }; + + it('tells a client-only app to add a /sign-in route that starts sign-in on load', async () => { + const prompt = await promptFor('react', 'workos-authkit-react'); + expect(prompt).toContain('## Sign-in route (Initiate login URI)'); + expect(prompt).toContain('the app origin plus /sign-in'); + expect(prompt).toContain('add a /sign-in client route'); + }); + + it("pins a server SDK to its guide's sign-in route without the client-only steps", async () => { + const prompt = await promptFor('kotlin', 'workos-kotlin'); + expect(prompt).toContain('the app origin plus /auth/login'); + expect(prompt).not.toContain('client route'); + }); + + it('adds no sign-in section when the framework has no fixed route', async () => { + const prompt = await promptFor('react-router', 'workos-authkit-react-router'); + expect(prompt).not.toContain('## Sign-in route (Initiate login URI)'); + }); + it('declines Pages Router before requesting credentials, writing files, or starting the agent', async () => { const framework = { ...config, diff --git a/src/lib/agent-runner.ts b/src/lib/agent-runner.ts index 52016bcb..571a804c 100644 --- a/src/lib/agent-runner.ts +++ b/src/lib/agent-runner.ts @@ -20,7 +20,7 @@ import { initializeAgent, runAgent, type RetryConfig } from './agent-interface.j import { uploadEnvironmentVariablesStep } from '../steps/index.js'; import { autoConfigureWorkOSEnvironment } from './workos-management.js'; import { assertSupportedNextJsRouter, assertNextjsSignInRouteAvailable } from '../integrations/nextjs/utils.js'; -import { detectPort, getCallbackPath } from './port-detection.js'; +import { detectPort, getCallbackPath, getSignInPath } from './port-detection.js'; import { writeEnvLocal } from './env-writer.js'; /** @@ -321,6 +321,7 @@ async function buildIntegrationPrompt( 'WORKOS_COOKIE_PASSWORD', ]; const envVarList = envVars.map((v) => `- ${v}`).join('\n'); + const signInSection = buildSignInSection(config.metadata.integration); return `You are integrating WorkOS AuthKit into this ${config.metadata.name} application. @@ -355,11 +356,31 @@ Do not claim the full integration or browser flows are verified. Report code imp : '' } -Report your progress using [STATUS] prefixes. +${signInSection}Report your progress using [STATUS] prefixes. Begin integration now.`; } +const CLIENT_ONLY_INTEGRATIONS = new Set(['react', 'vanilla-js']); + +/** Pin the sign-in route the installer saves as the Initiate login URI (Next.js has its own section). */ +function buildSignInSection(integration: FrameworkConfig['metadata']['integration']): string { + const signInPath = getSignInPath(integration); + if (!signInPath || integration === 'nextjs') return ''; + const lines = [ + '## Sign-in route (Initiate login URI)', + '', + `After you finish, the installer sets the WorkOS Initiate login URI to the app origin plus ${signInPath}. AuthKit sends users there when sign-in starts outside the app, such as from a password-reset email or an invitation. The route must exist at exactly ${signInPath}, must be public, and must start AuthKit sign-in through the SDK. It is never the callback route.`, + ]; + if (CLIENT_ONLY_INTEGRATIONS.has(integration)) { + lines.push( + '', + `This is a client-only app, so add a ${signInPath} client route that calls the SDK's redirect-based signIn() as soon as the AuthKit client is ready, without a click. Pass through the query parameters that the SDK README says signIn() accepts (for example, a login hint or invitation token). If the app has a client router, register ${signInPath} in it. If it has none, check window.location.pathname at startup. Keep the existing sign-in button. Do not add a server route or change the callback handling.`, + ); + } + return `${lines.join('\n')}\n\n`; +} + function buildCompletionSummary(config: FrameworkConfig, changes: string[], nextSteps: string[]): string { const lines: string[] = ['Successfully installed WorkOS AuthKit!', '']; diff --git a/src/lib/authkit-application-setup.spec.ts b/src/lib/authkit-application-setup.spec.ts index 6469a56d..dfc06105 100644 --- a/src/lib/authkit-application-setup.spec.ts +++ b/src/lib/authkit-application-setup.spec.ts @@ -18,7 +18,11 @@ vi.mock('../catalog/operation.js', () => ({ import { refreshIfExpired } from './command-auth.js'; import { fetchTeamEnvironments } from './environment-target.js'; import { dashboardGraphqlRequest } from './dashboard-graphql.js'; -import { configureAuthkitApplication, readNextjsApplicationSetup } from './authkit-application-setup.js'; +import { + buildApplicationSetup, + configureAuthkitApplication, + readNextjsApplicationSetup, +} from './authkit-application-setup.js'; import { applicationSetupNextSteps } from './completion-data.js'; const setup = { @@ -186,6 +190,24 @@ describe('native application URL setup', () => { expect(writes()).toHaveLength(2); }); + it('sets the sign-out URI and leaves the initiate login URI alone when the app has no sign-in route', async () => { + const { initiateLoginUri: _omitted, ...withoutSignIn } = setup; + const result = await configureAuthkitApplication(withoutSignIn, setup.clientId); + expect(result.verified).toBe(true); + expect(application.logoutUris).toContainEqual({ uri: setup.signOutUri, isDefault: true }); + expect(application.initiateLoginUri).toBeNull(); + for (const [name, options] of writes()) + if (name === 'updateAuthkitApplication') expect(options.variables?.input).not.toHaveProperty('initiateLoginUri'); + }); + + it('does not report an existing initiate login URI as a conflict when the app has no sign-in route', async () => { + application.initiateLoginUri = 'http://localhost:4000/login'; + const { initiateLoginUri: _omitted, ...withoutSignIn } = setup; + const result = await configureAuthkitApplication(withoutSignIn, setup.clientId); + expect(result.verified).toBe(true); + expect(application.initiateLoginUri).toBe('http://localhost:4000/login'); + }); + it('fills an empty dashboard homepage with the callback origin', async () => { delete application.appHomepageUrl; expect((await configureAuthkitApplication(setup, setup.clientId)).verified).toBe(true); @@ -582,4 +604,23 @@ describe('app URL derivation', () => { ); await expect(readNextjsApplicationSetup(directory)).rejects.toThrow('cannot use /sign-in'); }); + + it("builds a framework's initiate login URI from its own sign-in path", () => { + const result = buildApplicationSetup({ + clientId: 'client_app', + redirectUri: 'http://localhost:8080/auth/callback', + signInPath: '/auth/login', + }); + expect(result.initiateLoginUri).toBe('http://localhost:8080/auth/login'); + expect(result.signOutUri).toBe('http://localhost:8080/'); + }); + + it('omits the initiate login URI when the framework has no fixed sign-in path', () => { + const result = buildApplicationSetup({ + clientId: 'client_app', + redirectUri: 'http://localhost:3000/auth/callback', + }); + expect(result).not.toHaveProperty('initiateLoginUri'); + expect(result.signOutUri).toBe('http://localhost:3000/'); + }); }); diff --git a/src/lib/authkit-application-setup.ts b/src/lib/authkit-application-setup.ts index 8894860c..16006d00 100644 --- a/src/lib/authkit-application-setup.ts +++ b/src/lib/authkit-application-setup.ts @@ -7,12 +7,14 @@ import { dashboardGraphqlRequest } from './dashboard-graphql.js'; import { getOperation, resolveExecutableDocument } from '../catalog/operation.js'; import { InstallDeclinedError } from './installer-errors.js'; import { setHomepageUrl } from './workos-management.js'; +import { getSignInPath } from './port-detection.js'; export interface AuthkitApplicationSetup { clientId: string; redirectUri: string; signOutUri: string; - initiateLoginUri: string; + /** Absent when the framework has no fixed sign-in route to point at. */ + initiateLoginUri?: string; homepageUrl?: string; verified: boolean; /** The callback was registered; this alone does not verify the other URLs or browser flows. */ @@ -44,13 +46,28 @@ export async function readNextjsApplicationSetup( const clientId = env.WORKOS_CLIENT_ID; const redirectUri = env.NEXT_PUBLIC_WORKOS_REDIRECT_URI; if (!clientId || !redirectUri) throw new Error('Missing AuthKit client ID or callback URL in .env.local.'); + return buildApplicationSetup({ clientId, redirectUri, homepageUrl, signInPath: getSignInPath('nextjs') }); +} + +/** Derive the sign-out and initiate login URIs from the app's callback origin. */ +export function buildApplicationSetup({ + clientId, + redirectUri, + homepageUrl, + signInPath, +}: { + clientId: string; + redirectUri: string; + homepageUrl?: string; + signInPath?: string; +}): AuthkitApplicationSetup { const callback = new URL(redirectUri); if (!['http:', 'https:'].includes(callback.protocol) || callback.username || callback.password || callback.hash) { throw new Error('The AuthKit callback must be an HTTP(S) URL without credentials or a fragment.'); } - if (callback.pathname.replace(/\/$/, '') === '/sign-in') { + if (signInPath !== undefined && callback.pathname.replace(/\/$/, '') === signInPath.replace(/\/$/, '')) { throw new Error( - 'The OAuth callback cannot use /sign-in; that route starts authentication. Use a separate callback.', + `The OAuth callback cannot use ${signInPath}; that route starts authentication. Use a separate callback.`, ); } if (homepageUrl !== undefined) { @@ -64,7 +81,7 @@ export async function readNextjsApplicationSetup( redirectUri, ...(homepageUrl !== undefined ? { homepageUrl } : {}), signOutUri: `${callback.origin}/`, - initiateLoginUri: `${callback.origin}/sign-in`, + ...(signInPath !== undefined ? { initiateLoginUri: `${callback.origin}${signInPath}` } : {}), verified: false, }; } @@ -219,7 +236,10 @@ export async function configureAuthkitApplication( const reasons: string[] = []; const defaults = original.logoutUris.filter((uri) => uri.isDefault); const signOutConflict = defaults.length > 1 || defaults.some((uri) => !isSignOutDestination(uri.uri)); - const initiateConflict = !!original.initiateLoginUri && original.initiateLoginUri !== setup.initiateLoginUri; + const initiateConflict = + setup.initiateLoginUri !== undefined && + !!original.initiateLoginUri && + original.initiateLoginUri !== setup.initiateLoginUri; if (signOutConflict) reasons.push( 'An existing sign-out default differs from this app. It was left unchanged; confirm the intended default in the dashboard.', @@ -251,7 +271,8 @@ export async function configureAuthkitApplication( if (saved.setUserlandApplicationLogoutUris.__typename !== 'LogoutUrisSet') return pending('Could not save the sign-out URL. Check the dashboard before continuing.'); } - const needsInitiate = !initiateConflict && original.initiateLoginUri !== setup.initiateLoginUri; + const needsInitiate = + setup.initiateLoginUri !== undefined && !initiateConflict && original.initiateLoginUri !== setup.initiateLoginUri; const homepageValue = setup.homepageUrl ?? (original.appHomepageUrl || new URL(setup.redirectUri).origin); const needsHomepage = original.appHomepageUrl !== homepageValue; if (needsInitiate || needsHomepage) { @@ -297,7 +318,7 @@ export async function configureAuthkitApplication( ) || saved.logoutUris.filter((uri) => uri.isDefault).length !== 1 || !saved.logoutUris.some((uri) => isSignOutDestination(uri.uri) && uri.isDefault) || - saved.initiateLoginUri !== setup.initiateLoginUri || + (setup.initiateLoginUri !== undefined && saved.initiateLoginUri !== setup.initiateLoginUri) || saved.appHomepageUrl !== homepageValue ) return pending( diff --git a/src/lib/completion-data.ts b/src/lib/completion-data.ts index 501f256b..9480c8c4 100644 --- a/src/lib/completion-data.ts +++ b/src/lib/completion-data.ts @@ -10,7 +10,9 @@ export function applicationSetupNextSteps(setup: AuthkitApplicationSetup): strin : `Application setup is incomplete: ${setup.reason ?? 'Settings have not been verified.'}`, `Redirect URI: ${setup.redirectUri} (${setup.callbackRegistered || setup.verified ? 'registered' : 'not registered or verified'})`, `Sign-out URI: ${setup.signOutUri}`, - `Initiate login URI: ${setup.initiateLoginUri} (starts sign-in; never use the callback URI)`, + setup.initiateLoginUri !== undefined + ? `Initiate login URI: ${setup.initiateLoginUri} (starts sign-in; never use the callback URI)` + : 'Initiate login URI: not set. Point it at the app route that starts sign-in (never the callback URI).', ...(setup.homepageUrl !== undefined ? [`Homepage URL: ${setup.homepageUrl}`] : []), 'Test sign-in, sign-out, protected-page access, and a password-reset or invitation login before calling the integration complete.', ]; diff --git a/src/lib/port-detection.ts b/src/lib/port-detection.ts index 420de6ad..3e0125b5 100644 --- a/src/lib/port-detection.ts +++ b/src/lib/port-detection.ts @@ -34,6 +34,11 @@ export function getCallbackPath(integration: Integration): string { return settings.frameworks[settingsKey]?.callbackPath ?? DEFAULT_CALLBACK_PATH; } +/** The route that starts sign-in, or undefined when the SDK guide does not fix one. */ +export function getSignInPath(integration: Integration): string | undefined { + return settings.frameworks[INTEGRATION_TO_SETTINGS_KEY[integration]]?.signInPath; +} + /** * Parse port from Vite config file. * Looks for server.port in vite.config.{ts,js,mjs} diff --git a/src/lib/run-with-core.setup.spec.ts b/src/lib/run-with-core.setup.spec.ts index f571e964..f50d255e 100644 --- a/src/lib/run-with-core.setup.spec.ts +++ b/src/lib/run-with-core.setup.spec.ts @@ -3,7 +3,19 @@ import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import type { InstallerOptions } from '../utils/types.js'; -import { configureInstallEnvironment, reportAppUrlSetup } from './run-with-core.js'; +import { + configureInstallEnvironment, + configureOtherApplicationUrls, + NO_SIGN_IN_ROUTE_REASON, + reportAppUrlSetup, +} from './run-with-core.js'; +import { configureAuthkitApplication } from './authkit-application-setup.js'; +import { InstallDeclinedError } from './installer-errors.js'; + +vi.mock('./authkit-application-setup.js', async (importOriginal) => ({ + ...(await importOriginal()), + configureAuthkitApplication: vi.fn(), +})); import { createInstallerEventEmitter } from './events.js'; import { readProjectEnvCredentials } from './project-env.js'; @@ -234,6 +246,20 @@ describe('dashboard checklist reporting', () => { ]); }); + it('reports only the other two URLs when the environment step already reported the callback', async () => { + const { emitter, events } = record(); + const { initiateLoginUri: _omitted, ...withoutSignIn } = setup; + await reportAppUrlSetup(emitter, async () => ({ ...withoutSignIn, verified: true, callbackRegistered: true }), { + includeRedirect: false, + }); + expect(events).toEqual([ + 'app-urls:step initiate-login-uri started', + 'app-urls:step sign-out-uri started', + `app-urls:step initiate-login-uri skipped (${NO_SIGN_IN_ROUTE_REASON})`, + 'app-urls:step sign-out-uri done', + ]); + }); + it('leaves the items running when setup throws, so the failed install fails them', async () => { const { emitter, events } = record(); await expect( @@ -248,3 +274,86 @@ describe('dashboard checklist reporting', () => { ]); }); }); + +describe('application URLs for SDKs other than Next.js', () => { + beforeEach(() => { + vi.mocked(configureAuthkitApplication).mockReset(); + vi.mocked(configureAuthkitApplication).mockImplementation(async (setup) => ({ + ...setup, + verified: true, + callbackRegistered: true, + })); + }); + + it("saves a server SDK's fixed sign-in route and the origin as the sign-out URI", async () => { + const result = await configureOtherApplicationUrls( + { options, integration: 'go', emitter: createInstallerEventEmitter() }, + 'client_a', + 'sk_test_a', + ); + expect(vi.mocked(configureAuthkitApplication)).toHaveBeenCalledWith( + { + clientId: 'client_a', + redirectUri: 'http://localhost:8080/auth/callback', + signOutUri: 'http://localhost:8080/', + initiateLoginUri: 'http://localhost:8080/auth/login', + verified: false, + }, + 'client_a', + 'sk_test_a', + ); + expect(result?.verified).toBe(true); + }); + + it('saves only the sign-out URI for an SDK without a fixed sign-in route', async () => { + await configureOtherApplicationUrls( + { options, integration: 'react-router', emitter: createInstallerEventEmitter() }, + 'client_a', + 'sk_test_a', + ); + expect(vi.mocked(configureAuthkitApplication).mock.calls[0][0]).not.toHaveProperty('initiateLoginUri'); + }); + + it('saves the Vite /sign-in route once the app serves it', async () => { + await mkdir(join(directory, 'src'), { recursive: true }); + await writeFile(join(directory, 'package.json'), '{"dependencies":{"@workos-inc/authkit-react":"1"}}'); + await writeFile(join(directory, '.env.local'), 'VITE_WORKOS_CLIENT_ID=client_a\n'); + await writeFile( + join(directory, 'src/main.tsx'), + "import { AuthKitProvider, useAuth } from '@workos-inc/authkit-react';\n;\n", + ); + await writeFile( + join(directory, 'src/App.tsx'), + "if (window.location.pathname === '/sign-in') signIn();\nconst { signIn } = useAuth();\n", + ); + await configureOtherApplicationUrls( + { options, integration: 'react', emitter: createInstallerEventEmitter() }, + 'client_a', + 'sk_test_a', + ); + expect(vi.mocked(configureAuthkitApplication).mock.calls[0][0].initiateLoginUri).toBe( + 'http://localhost:5173/sign-in', + ); + }); + + it('does not point the dashboard at a Vite /sign-in route the app lacks', async () => { + await configureOtherApplicationUrls( + { options, integration: 'react', emitter: createInstallerEventEmitter() }, + 'client_a', + 'sk_test_a', + ); + expect(vi.mocked(configureAuthkitApplication).mock.calls[0][0]).not.toHaveProperty('initiateLoginUri'); + }); + + it('leaves the settings for the dashboard instead of failing the install', async () => { + vi.mocked(configureAuthkitApplication).mockRejectedValue( + new InstallDeclinedError('Automatic URL setup is restricted to sandbox environments.', 'callback_unregistered'), + ); + const emitter = createInstallerEventEmitter(); + const events: string[] = []; + emitter.on('app-urls:step', ({ step, status, detail }) => events.push(`${step} ${status} ${detail ?? ''}`.trim())); + const result = await configureOtherApplicationUrls({ options, integration: 'go', emitter }, 'client_a'); + expect(result?.verified).toBe(false); + expect(events).toContain('sign-out-uri skipped Automatic URL setup is restricted to sandbox environments.'); + }); +}); diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index 57a2cd27..25e7bad5 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -6,6 +6,7 @@ import type { CompletionData, InstallerEventEmitter, SetupItemId, SetupItemStatu import { buildCompletionData, applicationSetupNextSteps } from './completion-data.js'; import { readNextjsApplicationSetup, + buildApplicationSetup, configureAuthkitApplication, type AuthkitApplicationSetup, } from './authkit-application-setup.js'; @@ -59,7 +60,8 @@ import { getNextJsRouter, assertNextjsSignInRouteAvailable, } from '../integrations/nextjs/utils.js'; -import { detectPort, getCallbackPath } from './port-detection.js'; +import { detectPort, getCallbackPath, getSignInPath } from './port-detection.js'; +import { InstallDeclinedError } from './installer-errors.js'; import { writeEnvLocal } from './env-writer.js'; import { getRegistry } from './registry.js'; import { observeHostFailure } from './host-probe.js'; @@ -246,6 +248,8 @@ export async function configureInstallEnvironment( step('env-vars', 'done'); } +export const NO_SIGN_IN_ROUTE_REASON = 'This framework has no fixed sign-in route to use.'; + /** * Report the app URLs `configure` sets (the dashboard checklist's redirect, * initiate login, and sign-out URIs) as `app-urls:step` events. @@ -253,24 +257,68 @@ export async function configureInstallEnvironment( * `configureAuthkitApplication` throws when the callback isn't registered, * which fails the install (and the items still running with it). When it * returns, the callback is registered; the other two are verified together or - * not at all, with one reason covering both. + * not at all, with one reason covering both. Pass `includeRedirect: false` + * when the environment step already reported the callback. */ export async function reportAppUrlSetup( emitter: Pick, configure: () => Promise, + { includeRedirect = true }: { includeRedirect?: boolean } = {}, ): Promise { const step = (id: SetupItemId, status: SetupItemStatus, detail?: string) => emitter.emit('app-urls:step', { step: id, status, ...(detail ? { detail } : {}) }); - for (const id of ['redirect-uri', 'initiate-login-uri', 'sign-out-uri'] as const) step(id, 'started'); + const ids = ['initiate-login-uri', 'sign-out-uri'] as const; + for (const id of includeRedirect ? (['redirect-uri', ...ids] as const) : ids) step(id, 'started'); const setup = await configure(); - step('redirect-uri', 'done'); - for (const id of ['initiate-login-uri', 'sign-out-uri'] as const) { - if (setup.verified) step(id, 'done'); + if (includeRedirect) step('redirect-uri', 'done'); + for (const id of ids) { + if (id === 'initiate-login-uri' && setup.initiateLoginUri === undefined) + step(id, 'skipped', NO_SIGN_IN_ROUTE_REASON); + else if (setup.verified) step(id, 'done'); else step(id, 'skipped', setup.reason); } return setup; } +/** + * Set the sign-out and initiate login URIs for SDKs other than Next.js, after + * the agent has written the app. The environment step already registered the + * callback over the API key, so a failure here leaves the settings for the + * dashboard instead of failing an install whose code is done. + */ +export async function configureOtherApplicationUrls( + context: Pick, + clientId: string, + apiKey?: string, +): Promise { + const { options: installerOptions, integration } = context; + if (!integration || integration === 'nextjs') return undefined; + const port = detectPort(integration, installerOptions.installDir); + const redirectUri = installerOptions.redirectUri || `http://localhost:${port}${getCallbackPath(integration)}`; + // Point the dashboard at the sign-in route only when the app checks out. + const validation = await validateInstallation(integration, installerOptions.installDir, { runBuild: false }); + let setup: AuthkitApplicationSetup; + try { + setup = buildApplicationSetup({ + clientId, + redirectUri, + homepageUrl: installerOptions.homepageUrl, + signInPath: validation.passed ? getSignInPath(integration) : undefined, + }); + } catch { + return undefined; + } + return reportAppUrlSetup( + context.emitter, + () => + configureAuthkitApplication(setup, clientId, apiKey).catch((error: unknown) => { + if (!(error instanceof InstallDeclinedError)) throw error; + return { ...setup, verified: false, reason: error.message }; + }), + { includeRedirect: false }, + ); +} + /** Pick the installer adapter for this process's output mode and terminal. */ export function resolveAdapterKind(options: Pick): InstallerAdapterKind { return selectInstallerAdapter({ @@ -473,13 +521,16 @@ export async function runWithCore(options: InstallerOptions): Promise { applicationSetup = await reportAppUrlSetup(context.emitter, () => configureAuthkitApplication(setup, credentials?.clientId ?? '', credentials?.apiKey), ); + } else if (credentials?.clientId) { + applicationSetup = await configureOtherApplicationUrls(context, credentials.clientId, credentials.apiKey); } return { success: true, applicationSetup, - summary: applicationSetup - ? ['App code installed.', ...applicationSetupNextSteps(applicationSetup)].join('\n') - : summary || `Successfully installed WorkOS AuthKit for ${integration}!`, + summary: + integration === 'nextjs' && applicationSetup + ? ['App code installed.', ...applicationSetupNextSteps(applicationSetup)].join('\n') + : summary || `Successfully installed WorkOS AuthKit for ${integration}!`, }; } catch (error) { return { diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 30400ae7..656d8e5c 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -33,6 +33,8 @@ export interface InstallerConfig { [key: string]: { port: number; callbackPath: string; + /** The app route that starts sign-in (Initiate login URI), when the SDK guide fixes one. */ + signInPath?: string; }; }; legacy: { diff --git a/src/lib/validation/validator.spec.ts b/src/lib/validation/validator.spec.ts index 8c9cea02..ca82b468 100644 --- a/src/lib/validation/validator.spec.ts +++ b/src/lib/validation/validator.spec.ts @@ -749,6 +749,47 @@ describe('validateInstallation', () => { }); }); + describe('client-only sign-in route validation', () => { + const signInIssue = (issues: { message: string }[]) => issues.find((i) => i.message.includes('/sign-in route')); + + it('fails a React SPA without a /sign-in route', async () => { + mkdirSync(join(testDir, 'src'), { recursive: true }); + writeFileSync(join(testDir, 'src', 'App.tsx'), 'const { signIn } = useAuth();'); + + const result = await validateInstallation('react', testDir, { runBuild: false }); + + expect(signInIssue(result.issues)?.severity).toBe('error'); + expect(result.passed).toBe(false); + }); + + it('accepts a React SPA whose source serves /sign-in', async () => { + mkdirSync(join(testDir, 'src'), { recursive: true }); + writeFileSync(join(testDir, 'src', 'App.tsx'), '} />'); + + const result = await validateInstallation('react', testDir, { runBuild: false }); + + expect(signInIssue(result.issues)).toBeUndefined(); + }); + + it('accepts a vanilla JS app with a sign-in page', async () => { + mkdirSync(join(testDir, 'sign-in'), { recursive: true }); + writeFileSync(join(testDir, 'sign-in', 'index.html'), ''); + + const result = await validateInstallation('vanilla-js', testDir, { runBuild: false }); + + expect(signInIssue(result.issues)).toBeUndefined(); + }); + + it('ignores /sign-in mentions inside node_modules', async () => { + mkdirSync(join(testDir, 'node_modules', 'pkg'), { recursive: true }); + writeFileSync(join(testDir, 'node_modules', 'pkg', 'index.js'), "location.assign('/sign-in')"); + + const result = await validateInstallation('vanilla-js', testDir, { runBuild: false }); + + expect(signInIssue(result.issues)).toBeDefined(); + }); + }); + describe('duplicate env var detection', () => { it('detects conflicting values between .env and .env.local', async () => { writeFileSync( diff --git a/src/lib/validation/validator.ts b/src/lib/validation/validator.ts index 099d0036..e3bc09c9 100644 --- a/src/lib/validation/validator.ts +++ b/src/lib/validation/validator.ts @@ -4,7 +4,7 @@ import { join } from 'path'; import fg from 'fast-glob'; import type { ValidationResult, ValidationRules, ValidationIssue } from './types.js'; import { runBuildValidation } from './build-validator.js'; -import { detectPort } from '../port-detection.js'; +import { detectPort, getSignInPath } from '../port-detection.js'; import { nextjsRoutePath, findNextjsSignInPage } from '../../integrations/nextjs/utils.js'; import nextjsRules from './rules/nextjs.json' with { type: 'json' }; import reactRouterRules from './rules/react-router.json' with { type: 'json' }; @@ -258,6 +258,10 @@ export async function validateFrameworkSpecific(framework: string, projectDir: s } case 'react': await validateReactProviderWrapping(projectDir, issues); + await validateClientSignInRoute(framework, projectDir, issues); + break; + case 'vanilla-js': + await validateClientSignInRoute(framework, projectDir, issues); break; case 'react-router': await validateReactRouterRedirectUri(projectDir, issues); @@ -272,6 +276,39 @@ export async function validateFrameworkSpecific(framework: string, projectDir: s return issues; } +/** + * Client-only apps have no route files to match, so look for the sign-in path + * in the source, or a page that serves it. The installer saves this route as + * the Initiate login URI. + */ +async function validateClientSignInRoute(framework: string, projectDir: string, issues: ValidationIssue[]) { + const signInPath = getSignInPath(framework as Parameters[0]); + if (!signInPath) return; + const segment = signInPath.replace(/^\/|\/$/g, ''); + const pages = await fg( + [`${segment}.html`, `${segment}/index.html`, `public/${segment}.html`, `public/${segment}/index.html`], + { cwd: projectDir }, + ); + if (pages.length > 0) return; + const sources = await fg(['**/*.{ts,tsx,js,jsx,mjs,html,htm}'], { + cwd: projectDir, + ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.*/**'], + }); + for (const file of sources) { + try { + if ((await readFile(join(projectDir, file), 'utf-8')).includes(signInPath)) return; + } catch { + // Unreadable file - keep looking + } + } + issues.push({ + type: 'file', + severity: 'error', + message: `No ${signInPath} route starts sign-in`, + hint: `Add a public ${signInPath} client route that calls the SDK's signIn() on load. The installer saves it as the Initiate login URI.`, + }); +} + /** * Compares the redirect URI's effective port to the detected dev server port, * for local dev hosts only, and records an error-severity issue on mismatch. From 3831d082a9eaa8c50e56a4397378c50fbbe85364 Mon Sep 17 00:00:00 2001 From: Jason Barry Date: Thu, 24 Sep 2026 16:24:01 -0700 Subject: [PATCH 04/34] fix(install): align saved AuthKit URLs with each SDK's docs Checked the saved Initiate login and callback paths against the WorkOS docs and SDK READMEs. - React and vanilla JS use /login, as the authkit-react docs do, and must pass WORKOS_REDIRECT_URI as redirectUri: the SDK defaults to the page origin, which the installer never registers. Validation checks both. - Save the documented sign-in routes for React Router (/login), TanStack Start (/api/auth/sign-in), SvelteKit (/sign-in), Node (/login, which also fixes its outro copy) and plain PHP (/login.php). - SvelteKit defaults to Vite's 5173 and /callback, per its README. - Pin the route in the custom Ruby, Go, .NET and Elixir prompts too, so the agent cannot fall back to a path the dashboard does not point at. - Drop the query-parameter pass-through: AuthKit keeps password-reset and invitation details through the redirect. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018EgeNr2FXXVcJ5o165U5Lx --- src/cli.config.ts | 17 ++++++++++-- src/integrations/dotnet/index.ts | 3 +- src/integrations/elixir/index.ts | 3 +- src/integrations/go/index.ts | 3 +- src/integrations/node/index.ts | 2 +- src/integrations/ruby/index.ts | 3 +- src/lib/agent-runner.spec.ts | 22 +++++++++++---- src/lib/agent-runner.ts | 23 ++-------------- src/lib/port-detection.spec.ts | 34 ++++++++++++++++++++++- src/lib/port-detection.ts | 3 ++ src/lib/run-with-core.setup.spec.ts | 14 ++++++---- src/lib/sign-in-route.ts | 24 ++++++++++++++++ src/lib/validation/validator.spec.ts | 41 ++++++++++++++++++++++------ src/lib/validation/validator.ts | 36 ++++++++++++++++++------ 14 files changed, 173 insertions(+), 55 deletions(-) create mode 100644 src/lib/sign-in-route.ts diff --git a/src/cli.config.ts b/src/cli.config.ts index d6df1f16..03e17cb3 100644 --- a/src/cli.config.ts +++ b/src/cli.config.ts @@ -44,20 +44,22 @@ export const config = { react: { port: 5173, callbackPath: '/callback', - signInPath: '/sign-in', + signInPath: '/login', }, tanstackStart: { port: 3000, callbackPath: '/api/auth/callback', + signInPath: '/api/auth/sign-in', }, reactRouter: { port: 5173, callbackPath: '/callback', + signInPath: '/login', }, vanillaJs: { port: 5173, callbackPath: '/callback', - signInPath: '/sign-in', + signInPath: '/login', }, python: { port: 8000, @@ -72,6 +74,7 @@ export const config = { php: { port: 8000, callbackPath: '/auth/callback', + signInPath: '/login.php', }, phpLaravel: { port: 8000, @@ -93,6 +96,16 @@ export const config = { callbackPath: '/auth/callback', signInPath: '/auth/sign-in', }, + sveltekit: { + port: 5173, + callbackPath: '/callback', + signInPath: '/sign-in', + }, + node: { + port: 3000, + callbackPath: '/auth/callback', + signInPath: '/login', + }, kotlin: { port: 8080, callbackPath: '/auth/callback', diff --git a/src/integrations/dotnet/index.ts b/src/integrations/dotnet/index.ts index 4f1bf5d4..3ca72db7 100644 --- a/src/integrations/dotnet/index.ts +++ b/src/integrations/dotnet/index.ts @@ -19,6 +19,7 @@ import { initializeAgent, runAgent } from '../../lib/agent-interface.js'; import { autoConfigureWorkOSEnvironment } from '../../lib/workos-management.js'; import { validateInstallation } from '../../lib/validation/index.js'; import { getReference } from '../../lib/skills-assets.js'; +import { buildSignInSection } from '../../lib/sign-in-route.js'; export const config: FrameworkConfig = { metadata: { @@ -127,7 +128,7 @@ The following WorkOS credentials should be configured in appsettings.Development ${refContent} -Report your progress using [STATUS] prefixes. +${buildSignInSection(config.metadata.integration)}Report your progress using [STATUS] prefixes. Begin integration now.`; diff --git a/src/integrations/elixir/index.ts b/src/integrations/elixir/index.ts index 3ac2092b..a9925c58 100644 --- a/src/integrations/elixir/index.ts +++ b/src/integrations/elixir/index.ts @@ -9,6 +9,7 @@ import { getOrAskForWorkOSCredentials } from '../../utils/ui-utils.js'; import { initializeAgent, runAgent } from '../../lib/agent-interface.js'; import { writeEnvLocal } from '../../lib/env-writer.js'; import { getReference } from '../../lib/skills-assets.js'; +import { buildSignInSection } from '../../lib/sign-in-route.js'; export const config: FrameworkConfig = { metadata: { @@ -172,7 +173,7 @@ Note: For Elixir/Phoenix, these should be read via System.get_env() in config/ru ${refContent} -Report your progress using [STATUS] prefixes. +${buildSignInSection(config.metadata.integration)}Report your progress using [STATUS] prefixes. Begin integration now.`; } diff --git a/src/integrations/go/index.ts b/src/integrations/go/index.ts index efc9f8a1..6eb4897b 100644 --- a/src/integrations/go/index.ts +++ b/src/integrations/go/index.ts @@ -13,6 +13,7 @@ import { autoConfigureWorkOSEnvironment } from '../../lib/workos-management.js'; import { validateInstallation } from '../../lib/validation/index.js'; import { parseEnvFile } from '../../utils/env-parser.js'; import { getReference } from '../../lib/skills-assets.js'; +import { buildSignInSection } from '../../lib/sign-in-route.js'; /** Default port for Go HTTP servers */ const GO_DEFAULT_PORT = 8080; @@ -184,7 +185,7 @@ The following environment variables have been configured in .env: ${refContent} -Report your progress using [STATUS] prefixes. +${buildSignInSection(config.metadata.integration)}Report your progress using [STATUS] prefixes. Begin integration now.`; diff --git a/src/integrations/node/index.ts b/src/integrations/node/index.ts index 7745ae01..6c334de9 100644 --- a/src/integrations/node/index.ts +++ b/src/integrations/node/index.ts @@ -41,7 +41,7 @@ export const config: FrameworkConfig = { getOutroChanges: () => [ 'Analyzed your Express project structure', 'Installed and configured @workos-inc/node SDK', - 'Created authentication routes (/auth/login, /auth/callback, /auth/logout)', + 'Created authentication routes (/login, /auth/callback, /logout)', 'Configured session management', ], getOutroNextSteps: () => [ diff --git a/src/integrations/ruby/index.ts b/src/integrations/ruby/index.ts index 033c2010..93f20fb9 100644 --- a/src/integrations/ruby/index.ts +++ b/src/integrations/ruby/index.ts @@ -9,6 +9,7 @@ import { initializeAgent, runAgent } from '../../lib/agent-interface.js'; import { getOrAskForWorkOSCredentials } from '../../utils/ui-utils.js'; import { autoConfigureWorkOSEnvironment } from '../../lib/workos-management.js'; import { getReference } from '../../lib/skills-assets.js'; +import { buildSignInSection } from '../../lib/sign-in-route.js'; export const config: FrameworkConfig = { metadata: { @@ -114,7 +115,7 @@ The following environment variables are needed (create a .env file if one does n ${refContent} -Report your progress using [STATUS] prefixes. +${buildSignInSection(config.metadata.integration)}Report your progress using [STATUS] prefixes. Begin integration now.`; diff --git a/src/lib/agent-runner.spec.ts b/src/lib/agent-runner.spec.ts index 584d1701..4a2e7624 100644 --- a/src/lib/agent-runner.spec.ts +++ b/src/lib/agent-runner.spec.ts @@ -112,11 +112,12 @@ describe('installer prompt', () => { return vi.mocked(runAgent).mock.calls[0][1]; }; - it('tells a client-only app to add a /sign-in route that starts sign-in on load', async () => { + it('tells a client-only app to add a /login route and pass its redirect URI', async () => { const prompt = await promptFor('react', 'workos-authkit-react'); expect(prompt).toContain('## Sign-in route (Initiate login URI)'); - expect(prompt).toContain('the app origin plus /sign-in'); - expect(prompt).toContain('add a /sign-in client route'); + expect(prompt).toContain('the app origin plus /login'); + expect(prompt).toContain('add a /login client route'); + expect(prompt).toContain('set redirectUri on AuthKitProvider or createClient()'); }); it("pins a server SDK to its guide's sign-in route without the client-only steps", async () => { @@ -125,8 +126,19 @@ describe('installer prompt', () => { expect(prompt).not.toContain('client route'); }); - it('adds no sign-in section when the framework has no fixed route', async () => { - const prompt = await promptFor('react-router', 'workos-authkit-react-router'); + it.each([ + ['react-router', '/login'], + ['tanstack-start', '/api/auth/sign-in'], + ['sveltekit', '/sign-in'], + ['node', '/login'], + ['php', '/login.php'], + ])('pins %s to its documented sign-in route %s', async (integration, path) => { + const prompt = await promptFor(integration, `workos-${integration}`); + expect(prompt).toContain(`the app origin plus ${path}.`); + }); + + it('leaves Next.js to its own sign-in instructions', async () => { + const prompt = await promptFor('nextjs', 'workos-authkit-nextjs'); expect(prompt).not.toContain('## Sign-in route (Initiate login URI)'); }); diff --git a/src/lib/agent-runner.ts b/src/lib/agent-runner.ts index 571a804c..49a678c0 100644 --- a/src/lib/agent-runner.ts +++ b/src/lib/agent-runner.ts @@ -20,7 +20,8 @@ import { initializeAgent, runAgent, type RetryConfig } from './agent-interface.j import { uploadEnvironmentVariablesStep } from '../steps/index.js'; import { autoConfigureWorkOSEnvironment } from './workos-management.js'; import { assertSupportedNextJsRouter, assertNextjsSignInRouteAvailable } from '../integrations/nextjs/utils.js'; -import { detectPort, getCallbackPath, getSignInPath } from './port-detection.js'; +import { detectPort, getCallbackPath } from './port-detection.js'; +import { buildSignInSection } from './sign-in-route.js'; import { writeEnvLocal } from './env-writer.js'; /** @@ -361,26 +362,6 @@ ${signInSection}Report your progress using [STATUS] prefixes. Begin integration now.`; } -const CLIENT_ONLY_INTEGRATIONS = new Set(['react', 'vanilla-js']); - -/** Pin the sign-in route the installer saves as the Initiate login URI (Next.js has its own section). */ -function buildSignInSection(integration: FrameworkConfig['metadata']['integration']): string { - const signInPath = getSignInPath(integration); - if (!signInPath || integration === 'nextjs') return ''; - const lines = [ - '## Sign-in route (Initiate login URI)', - '', - `After you finish, the installer sets the WorkOS Initiate login URI to the app origin plus ${signInPath}. AuthKit sends users there when sign-in starts outside the app, such as from a password-reset email or an invitation. The route must exist at exactly ${signInPath}, must be public, and must start AuthKit sign-in through the SDK. It is never the callback route.`, - ]; - if (CLIENT_ONLY_INTEGRATIONS.has(integration)) { - lines.push( - '', - `This is a client-only app, so add a ${signInPath} client route that calls the SDK's redirect-based signIn() as soon as the AuthKit client is ready, without a click. Pass through the query parameters that the SDK README says signIn() accepts (for example, a login hint or invitation token). If the app has a client router, register ${signInPath} in it. If it has none, check window.location.pathname at startup. Keep the existing sign-in button. Do not add a server route or change the callback handling.`, - ); - } - return `${lines.join('\n')}\n\n`; -} - function buildCompletionSummary(config: FrameworkConfig, changes: string[], nextSteps: string[]): string { const lines: string[] = ['Successfully installed WorkOS AuthKit!', '']; diff --git a/src/lib/port-detection.spec.ts b/src/lib/port-detection.spec.ts index ea574cfd..019f8e23 100644 --- a/src/lib/port-detection.spec.ts +++ b/src/lib/port-detection.spec.ts @@ -2,7 +2,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { detectPort, getCallbackPath } from './port-detection.js'; +import { detectPort, getCallbackPath, getSignInPath } from './port-detection.js'; +import { buildSignInSection } from './sign-in-route.js'; describe('port-detection — python/Django defaults', () => { let dir: string; @@ -225,3 +226,34 @@ describe('port-detection — vite frameworks', () => { }, ); }); + +describe('sign-in routes from the SDK docs', () => { + it('uses the SvelteKit README defaults', () => { + expect(detectPort('sveltekit', '/nonexistent')).toBe(5173); + expect(getCallbackPath('sveltekit')).toBe('/callback'); + expect(getSignInPath('sveltekit')).toBe('/sign-in'); + }); + + it.each([ + ['nextjs', '/sign-in'], + ['react', '/login'], + ['vanilla-js', '/login'], + ['react-router', '/login'], + ['tanstack-start', '/api/auth/sign-in'], + ['node', '/login'], + ['python', '/auth/login/'], + ['ruby', '/auth/login'], + ['php', '/login.php'], + ['php-laravel', '/login'], + ['go', '/auth/login'], + ['dotnet', '/auth/login'], + ['kotlin', '/auth/login'], + ['elixir', '/auth/sign-in'], + ])('%s starts sign-in at %s', (integration, path) => { + expect(getSignInPath(integration)).toBe(path); + }); + + it.each(['ruby', 'go', 'dotnet', 'elixir'])('gives the custom %s prompt the route to pin', (integration) => { + expect(buildSignInSection(integration)).toContain(`the app origin plus ${getSignInPath(integration)}.`); + }); +}); diff --git a/src/lib/port-detection.ts b/src/lib/port-detection.ts index 3e0125b5..a8e2875f 100644 --- a/src/lib/port-detection.ts +++ b/src/lib/port-detection.ts @@ -11,6 +11,8 @@ const INTEGRATION_TO_SETTINGS_KEY: Record = { 'tanstack-start': 'tanstackStart', 'react-router': 'reactRouter', 'vanilla-js': 'vanillaJs', + sveltekit: 'sveltekit', + node: 'node', python: 'python', ruby: 'ruby', php: 'php', @@ -233,6 +235,7 @@ export function detectPort(integration: Integration, installDir: string): number case 'react': case 'react-router': case 'vanilla-js': + case 'sveltekit': // Vite-based frameworks detectedPort = parseViteConfigPortFromDir(installDir); break; diff --git a/src/lib/run-with-core.setup.spec.ts b/src/lib/run-with-core.setup.spec.ts index f50d255e..6ef93bcd 100644 --- a/src/lib/run-with-core.setup.spec.ts +++ b/src/lib/run-with-core.setup.spec.ts @@ -305,7 +305,7 @@ describe('application URLs for SDKs other than Next.js', () => { expect(result?.verified).toBe(true); }); - it('saves only the sign-out URI for an SDK without a fixed sign-in route', async () => { + it('leaves out the initiate login URI when the app fails validation', async () => { await configureOtherApplicationUrls( { options, integration: 'react-router', emitter: createInstallerEventEmitter() }, 'client_a', @@ -314,7 +314,7 @@ describe('application URLs for SDKs other than Next.js', () => { expect(vi.mocked(configureAuthkitApplication).mock.calls[0][0]).not.toHaveProperty('initiateLoginUri'); }); - it('saves the Vite /sign-in route once the app serves it', async () => { + it('saves the Vite /login route once the app serves it', async () => { await mkdir(join(directory, 'src'), { recursive: true }); await writeFile(join(directory, 'package.json'), '{"dependencies":{"@workos-inc/authkit-react":"1"}}'); await writeFile(join(directory, '.env.local'), 'VITE_WORKOS_CLIENT_ID=client_a\n'); @@ -324,7 +324,11 @@ describe('application URLs for SDKs other than Next.js', () => { ); await writeFile( join(directory, 'src/App.tsx'), - "if (window.location.pathname === '/sign-in') signIn();\nconst { signIn } = useAuth();\n", + "if (window.location.pathname === '/login') signIn();\nconst { signIn } = useAuth();\n", + ); + await writeFile( + join(directory, 'src/config.ts'), + 'export const redirectUri = import.meta.env.VITE_WORKOS_REDIRECT_URI;\n', ); await configureOtherApplicationUrls( { options, integration: 'react', emitter: createInstallerEventEmitter() }, @@ -332,11 +336,11 @@ describe('application URLs for SDKs other than Next.js', () => { 'sk_test_a', ); expect(vi.mocked(configureAuthkitApplication).mock.calls[0][0].initiateLoginUri).toBe( - 'http://localhost:5173/sign-in', + 'http://localhost:5173/login', ); }); - it('does not point the dashboard at a Vite /sign-in route the app lacks', async () => { + it('does not point the dashboard at a Vite /login route the app lacks', async () => { await configureOtherApplicationUrls( { options, integration: 'react', emitter: createInstallerEventEmitter() }, 'client_a', diff --git a/src/lib/sign-in-route.ts b/src/lib/sign-in-route.ts new file mode 100644 index 00000000..900c4730 --- /dev/null +++ b/src/lib/sign-in-route.ts @@ -0,0 +1,24 @@ +import type { Integration } from './constants.js'; +import { getSignInPath } from './port-detection.js'; + +const CLIENT_ONLY_INTEGRATIONS = new Set(['react', 'vanilla-js']); + +/** Pin the sign-in route the installer saves as the Initiate login URI (Next.js has its own section). */ +export function buildSignInSection(integration: Integration): string { + const signInPath = getSignInPath(integration); + if (!signInPath || integration === 'nextjs') return ''; + const lines = [ + '## Sign-in route (Initiate login URI)', + '', + `After you finish, the installer sets the WorkOS Initiate login URI to the app origin plus ${signInPath}. AuthKit sends users there when sign-in starts outside the app, such as from a password-reset email or an invitation. The route must exist at exactly ${signInPath}, must be public, and must start AuthKit sign-in through the SDK. It is never the callback route.`, + ]; + if (CLIENT_ONLY_INTEGRATIONS.has(integration)) { + lines.push( + '', + `This is a client-only app, so add a ${signInPath} client route that calls the SDK's signIn() as soon as the AuthKit client is ready, without a click (for example, in a useEffect). AuthKit keeps password-reset and invitation details through this redirect, so signIn() needs no extra arguments. If the app has a client router, register ${signInPath} in it. If it has none, check window.location.pathname at startup. Keep the existing sign-in button. Do not add a server route.`, + '', + "The installer registers WORKOS_REDIRECT_URI as the app's Redirect URI. The SDK defaults to the page origin instead, so pass that value explicitly: expose it with the build tool's env prefix (for example, VITE_WORKOS_REDIRECT_URI) and set redirectUri on AuthKitProvider or createClient().", + ); + } + return `${lines.join('\n')}\n\n`; +} diff --git a/src/lib/validation/validator.spec.ts b/src/lib/validation/validator.spec.ts index ca82b468..9607e23b 100644 --- a/src/lib/validation/validator.spec.ts +++ b/src/lib/validation/validator.spec.ts @@ -750,9 +750,10 @@ describe('validateInstallation', () => { }); describe('client-only sign-in route validation', () => { - const signInIssue = (issues: { message: string }[]) => issues.find((i) => i.message.includes('/sign-in route')); + const signInIssue = (issues: { message: string }[]) => issues.find((i) => i.message.includes('/login route')); + const redirectIssue = (issues: { message: string }[]) => issues.find((i) => i.message.includes('redirectUri')); - it('fails a React SPA without a /sign-in route', async () => { + it('fails a React SPA without a /login route', async () => { mkdirSync(join(testDir, 'src'), { recursive: true }); writeFileSync(join(testDir, 'src', 'App.tsx'), 'const { signIn } = useAuth();'); @@ -762,9 +763,9 @@ describe('validateInstallation', () => { expect(result.passed).toBe(false); }); - it('accepts a React SPA whose source serves /sign-in', async () => { + it('accepts a React SPA whose source serves /login', async () => { mkdirSync(join(testDir, 'src'), { recursive: true }); - writeFileSync(join(testDir, 'src', 'App.tsx'), '} />'); + writeFileSync(join(testDir, 'src', 'App.tsx'), '} />'); const result = await validateInstallation('react', testDir, { runBuild: false }); @@ -772,22 +773,46 @@ describe('validateInstallation', () => { }); it('accepts a vanilla JS app with a sign-in page', async () => { - mkdirSync(join(testDir, 'sign-in'), { recursive: true }); - writeFileSync(join(testDir, 'sign-in', 'index.html'), ''); + mkdirSync(join(testDir, 'login'), { recursive: true }); + writeFileSync(join(testDir, 'login', 'index.html'), ''); const result = await validateInstallation('vanilla-js', testDir, { runBuild: false }); expect(signInIssue(result.issues)).toBeUndefined(); }); - it('ignores /sign-in mentions inside node_modules', async () => { + it('ignores /login mentions inside node_modules', async () => { mkdirSync(join(testDir, 'node_modules', 'pkg'), { recursive: true }); - writeFileSync(join(testDir, 'node_modules', 'pkg', 'index.js'), "location.assign('/sign-in')"); + writeFileSync(join(testDir, 'node_modules', 'pkg', 'index.js'), "location.assign('/login')"); const result = await validateInstallation('vanilla-js', testDir, { runBuild: false }); expect(signInIssue(result.issues)).toBeDefined(); }); + + it('fails a client app that leaves redirectUri at the SDK default', async () => { + mkdirSync(join(testDir, 'src'), { recursive: true }); + writeFileSync( + join(testDir, 'src', 'main.tsx'), + '', + ); + + const result = await validateInstallation('react', testDir, { runBuild: false }); + + expect(redirectIssue(result.issues)?.severity).toBe('error'); + }); + + it('accepts a client app that passes redirectUri', async () => { + mkdirSync(join(testDir, 'src'), { recursive: true }); + writeFileSync( + join(testDir, 'src', 'main.ts'), + 'createClient(clientId, { redirectUri: import.meta.env.VITE_WORKOS_REDIRECT_URI });', + ); + + const result = await validateInstallation('vanilla-js', testDir, { runBuild: false }); + + expect(redirectIssue(result.issues)).toBeUndefined(); + }); }); describe('duplicate env var detection', () => { diff --git a/src/lib/validation/validator.ts b/src/lib/validation/validator.ts index e3bc09c9..407bdaf2 100644 --- a/src/lib/validation/validator.ts +++ b/src/lib/validation/validator.ts @@ -259,9 +259,11 @@ export async function validateFrameworkSpecific(framework: string, projectDir: s case 'react': await validateReactProviderWrapping(projectDir, issues); await validateClientSignInRoute(framework, projectDir, issues); + await validateClientRedirectUri(projectDir, issues); break; case 'vanilla-js': await validateClientSignInRoute(framework, projectDir, issues); + await validateClientRedirectUri(projectDir, issues); break; case 'react-router': await validateReactRouterRedirectUri(projectDir, issues); @@ -289,24 +291,42 @@ async function validateClientSignInRoute(framework: string, projectDir: string, [`${segment}.html`, `${segment}/index.html`, `public/${segment}.html`, `public/${segment}/index.html`], { cwd: projectDir }, ); - if (pages.length > 0) return; + if (pages.length > 0 || (await clientSourceContains(projectDir, signInPath))) return; + issues.push({ + type: 'file', + severity: 'error', + message: `No ${signInPath} route starts sign-in`, + hint: `Add a public ${signInPath} client route that calls the SDK's signIn() on load. The installer saves it as the Initiate login URI.`, + }); +} + +/** + * The client SDKs default their redirect URI to the page origin, but the + * installer registers WORKOS_REDIRECT_URI, so the app must pass it. + */ +async function validateClientRedirectUri(projectDir: string, issues: ValidationIssue[]) { + if (await clientSourceContains(projectDir, 'redirectUri')) return; + issues.push({ + type: 'pattern', + severity: 'error', + message: 'The AuthKit client does not set redirectUri', + hint: 'Pass WORKOS_REDIRECT_URI (with the build tool env prefix) as redirectUri to AuthKitProvider or createClient(). The SDK default, the page origin, is not registered.', + }); +} + +async function clientSourceContains(projectDir: string, needle: string): Promise { const sources = await fg(['**/*.{ts,tsx,js,jsx,mjs,html,htm}'], { cwd: projectDir, ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.*/**'], }); for (const file of sources) { try { - if ((await readFile(join(projectDir, file), 'utf-8')).includes(signInPath)) return; + if ((await readFile(join(projectDir, file), 'utf-8')).includes(needle)) return true; } catch { // Unreadable file - keep looking } } - issues.push({ - type: 'file', - severity: 'error', - message: `No ${signInPath} route starts sign-in`, - hint: `Add a public ${signInPath} client route that calls the SDK's signIn() on load. The installer saves it as the Initiate login URI.`, - }); + return false; } /** From 50276b9ac80e7cd6bf0af9bdb48d0f25f5cff51f Mon Sep 17 00:00:00 2001 From: Jason Barry Date: Thu, 24 Sep 2026 16:30:08 -0700 Subject: [PATCH 05/34] refactor(install): simplify AuthKit URL setup across SDKs - Build the default redirect URI in one resolveRedirectUri() helper. - Derive client-only SDKs from requiresApiKey instead of a second list. - Scan client source once, in parallel, for both the sign-in route and redirectUri; check only that route after the agent instead of rerunning the full validator. - Report why an initiate login URI was skipped (no fixed route vs. a missing route) and show an unusable callback URL in the checklist. - Give the Python prompt the pinned sign-in route like the other SDKs. - Read the Node outro routes from config. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018EgeNr2FXXVcJ5o165U5Lx --- src/integrations/dotnet/index.ts | 2 +- src/integrations/elixir/index.ts | 2 +- src/integrations/go/index.ts | 2 +- src/integrations/node/index.ts | 3 +- src/integrations/python/index.ts | 3 +- src/integrations/ruby/index.ts | 2 +- src/lib/agent-runner.spec.ts | 10 +++- src/lib/agent-runner.ts | 8 +-- src/lib/authkit-application-setup.ts | 16 +++--- src/lib/port-detection.spec.ts | 15 ++++- src/lib/port-detection.ts | 11 ++++ src/lib/run-with-core.setup.spec.ts | 42 ++++++++++---- src/lib/run-with-core.ts | 52 ++++++++++------- src/lib/sign-in-route.ts | 13 +++-- src/lib/validation/validator.ts | 85 +++++++++++++++------------- 15 files changed, 166 insertions(+), 100 deletions(-) diff --git a/src/integrations/dotnet/index.ts b/src/integrations/dotnet/index.ts index 3ca72db7..8faeb7fa 100644 --- a/src/integrations/dotnet/index.ts +++ b/src/integrations/dotnet/index.ts @@ -128,7 +128,7 @@ The following WorkOS credentials should be configured in appsettings.Development ${refContent} -${buildSignInSection(config.metadata.integration)}Report your progress using [STATUS] prefixes. +${buildSignInSection(config)}Report your progress using [STATUS] prefixes. Begin integration now.`; diff --git a/src/integrations/elixir/index.ts b/src/integrations/elixir/index.ts index a9925c58..2404a49e 100644 --- a/src/integrations/elixir/index.ts +++ b/src/integrations/elixir/index.ts @@ -173,7 +173,7 @@ Note: For Elixir/Phoenix, these should be read via System.get_env() in config/ru ${refContent} -${buildSignInSection(config.metadata.integration)}Report your progress using [STATUS] prefixes. +${buildSignInSection(config)}Report your progress using [STATUS] prefixes. Begin integration now.`; } diff --git a/src/integrations/go/index.ts b/src/integrations/go/index.ts index 6eb4897b..d821a894 100644 --- a/src/integrations/go/index.ts +++ b/src/integrations/go/index.ts @@ -185,7 +185,7 @@ The following environment variables have been configured in .env: ${refContent} -${buildSignInSection(config.metadata.integration)}Report your progress using [STATUS] prefixes. +${buildSignInSection(config)}Report your progress using [STATUS] prefixes. Begin integration now.`; diff --git a/src/integrations/node/index.ts b/src/integrations/node/index.ts index 6c334de9..2a479575 100644 --- a/src/integrations/node/index.ts +++ b/src/integrations/node/index.ts @@ -3,6 +3,7 @@ import type { FrameworkConfig } from '../../lib/framework-config.js'; import type { InstallerOptions } from '../../utils/types.js'; import { enableDebugLogs } from '../../utils/debug.js'; import { getPackageVersion } from '../../utils/package-json.js'; +import { getCallbackPath, getSignInPath } from '../../lib/port-detection.js'; export const config: FrameworkConfig = { metadata: { @@ -41,7 +42,7 @@ export const config: FrameworkConfig = { getOutroChanges: () => [ 'Analyzed your Express project structure', 'Installed and configured @workos-inc/node SDK', - 'Created authentication routes (/login, /auth/callback, /logout)', + `Created authentication routes (${getSignInPath('node')}, ${getCallbackPath('node')}, /logout)`, 'Configured session management', ], getOutroNextSteps: () => [ diff --git a/src/integrations/python/index.ts b/src/integrations/python/index.ts index 45267187..8d6b503b 100644 --- a/src/integrations/python/index.ts +++ b/src/integrations/python/index.ts @@ -8,6 +8,7 @@ import { analytics } from '../../utils/analytics.js'; import { INSTALLER_INTERACTION_EVENT_NAME } from '../../lib/constants.js'; import { parseEnvFile } from '../../utils/env-parser.js'; import { getReference } from '../../lib/skills-assets.js'; +import { buildSignInSection } from '../../lib/sign-in-route.js'; /** * Detect which Python package manager the project uses. @@ -186,7 +187,7 @@ The following environment variables have been configured in .env: ${refContent} -Report your progress using [STATUS] prefixes. +${buildSignInSection(config)}Report your progress using [STATUS] prefixes. Begin integration now.`; } diff --git a/src/integrations/ruby/index.ts b/src/integrations/ruby/index.ts index 93f20fb9..30c1919e 100644 --- a/src/integrations/ruby/index.ts +++ b/src/integrations/ruby/index.ts @@ -115,7 +115,7 @@ The following environment variables are needed (create a .env file if one does n ${refContent} -${buildSignInSection(config.metadata.integration)}Report your progress using [STATUS] prefixes. +${buildSignInSection(config)}Report your progress using [STATUS] prefixes. Begin integration now.`; diff --git a/src/lib/agent-runner.spec.ts b/src/lib/agent-runner.spec.ts index 4a2e7624..fb488daa 100644 --- a/src/lib/agent-runner.spec.ts +++ b/src/lib/agent-runner.spec.ts @@ -108,7 +108,15 @@ describe('installer prompt', () => { }); const promptFor = async (integration: FrameworkConfig['metadata']['integration'], skillName: string) => { - await runAgentInstaller({ ...config, metadata: { ...config.metadata, integration, skillName } }, options); + const requiresApiKey = !['react', 'vanilla-js'].includes(integration); + await runAgentInstaller( + { + ...config, + metadata: { ...config.metadata, integration, skillName }, + environment: { ...config.environment, requiresApiKey }, + }, + options, + ); return vi.mocked(runAgent).mock.calls[0][1]; }; diff --git a/src/lib/agent-runner.ts b/src/lib/agent-runner.ts index 49a678c0..6e29d2f6 100644 --- a/src/lib/agent-runner.ts +++ b/src/lib/agent-runner.ts @@ -20,7 +20,7 @@ import { initializeAgent, runAgent, type RetryConfig } from './agent-interface.j import { uploadEnvironmentVariablesStep } from '../steps/index.js'; import { autoConfigureWorkOSEnvironment } from './workos-management.js'; import { assertSupportedNextJsRouter, assertNextjsSignInRouteAvailable } from '../integrations/nextjs/utils.js'; -import { detectPort, getCallbackPath } from './port-detection.js'; +import { detectPort, resolveRedirectUri } from './port-detection.js'; import { buildSignInSection } from './sign-in-route.js'; import { writeEnvLocal } from './env-writer.js'; @@ -86,9 +86,7 @@ export async function runAgentInstaller(config: FrameworkConfig, options: Instal // Write environment variables to .env.local BEFORE agent runs // Skip if caller already handled this (prevents double-writing) if (!callerHandledConfig) { - const port = detectPort(config.metadata.integration, options.installDir); - const callbackPath = getCallbackPath(config.metadata.integration); - const redirectUri = options.redirectUri || `http://localhost:${port}${callbackPath}`; + const redirectUri = resolveRedirectUri(config.metadata.integration, options); // Next.js requires NEXT_PUBLIC_ prefix for client-side env vars const redirectUriKey = @@ -322,7 +320,7 @@ async function buildIntegrationPrompt( 'WORKOS_COOKIE_PASSWORD', ]; const envVarList = envVars.map((v) => `- ${v}`).join('\n'); - const signInSection = buildSignInSection(config.metadata.integration); + const signInSection = buildSignInSection(config); return `You are integrating WorkOS AuthKit into this ${config.metadata.name} application. diff --git a/src/lib/authkit-application-setup.ts b/src/lib/authkit-application-setup.ts index 16006d00..4e36f428 100644 --- a/src/lib/authkit-application-setup.ts +++ b/src/lib/authkit-application-setup.ts @@ -13,8 +13,9 @@ export interface AuthkitApplicationSetup { clientId: string; redirectUri: string; signOutUri: string; - /** Absent when the framework has no fixed sign-in route to point at. */ + /** Absent when there is no sign-in route to point at; `initiateLoginReason` says why. */ initiateLoginUri?: string; + initiateLoginReason?: string; homepageUrl?: string; verified: boolean; /** The callback was registered; this alone does not verify the other URLs or browser flows. */ @@ -236,10 +237,10 @@ export async function configureAuthkitApplication( const reasons: string[] = []; const defaults = original.logoutUris.filter((uri) => uri.isDefault); const signOutConflict = defaults.length > 1 || defaults.some((uri) => !isSignOutDestination(uri.uri)); - const initiateConflict = - setup.initiateLoginUri !== undefined && - !!original.initiateLoginUri && - original.initiateLoginUri !== setup.initiateLoginUri; + // True when this app has an initiate login URI that `uri` does not already hold. + const differsFromInitiate = (uri: string | null) => + setup.initiateLoginUri !== undefined && uri !== setup.initiateLoginUri; + const initiateConflict = !!original.initiateLoginUri && differsFromInitiate(original.initiateLoginUri); if (signOutConflict) reasons.push( 'An existing sign-out default differs from this app. It was left unchanged; confirm the intended default in the dashboard.', @@ -271,8 +272,7 @@ export async function configureAuthkitApplication( if (saved.setUserlandApplicationLogoutUris.__typename !== 'LogoutUrisSet') return pending('Could not save the sign-out URL. Check the dashboard before continuing.'); } - const needsInitiate = - setup.initiateLoginUri !== undefined && !initiateConflict && original.initiateLoginUri !== setup.initiateLoginUri; + const needsInitiate = !original.initiateLoginUri && differsFromInitiate(original.initiateLoginUri); const homepageValue = setup.homepageUrl ?? (original.appHomepageUrl || new URL(setup.redirectUri).origin); const needsHomepage = original.appHomepageUrl !== homepageValue; if (needsInitiate || needsHomepage) { @@ -318,7 +318,7 @@ export async function configureAuthkitApplication( ) || saved.logoutUris.filter((uri) => uri.isDefault).length !== 1 || !saved.logoutUris.some((uri) => isSignOutDestination(uri.uri) && uri.isDefault) || - (setup.initiateLoginUri !== undefined && saved.initiateLoginUri !== setup.initiateLoginUri) || + differsFromInitiate(saved.initiateLoginUri) || saved.appHomepageUrl !== homepageValue ) return pending( diff --git a/src/lib/port-detection.spec.ts b/src/lib/port-detection.spec.ts index 019f8e23..421e4552 100644 --- a/src/lib/port-detection.spec.ts +++ b/src/lib/port-detection.spec.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { detectPort, getCallbackPath, getSignInPath } from './port-detection.js'; import { buildSignInSection } from './sign-in-route.js'; +import type { FrameworkConfig } from './framework-config.js'; describe('port-detection — python/Django defaults', () => { let dir: string; @@ -253,7 +254,15 @@ describe('sign-in routes from the SDK docs', () => { expect(getSignInPath(integration)).toBe(path); }); - it.each(['ruby', 'go', 'dotnet', 'elixir'])('gives the custom %s prompt the route to pin', (integration) => { - expect(buildSignInSection(integration)).toContain(`the app origin plus ${getSignInPath(integration)}.`); - }); + it.each(['ruby', 'go', 'dotnet', 'elixir', 'python'])( + 'gives the custom %s prompt the route to pin', + (integration) => { + expect( + buildSignInSection({ + metadata: { integration } as FrameworkConfig['metadata'], + environment: { requiresApiKey: true } as FrameworkConfig['environment'], + }), + ).toContain(`the app origin plus ${getSignInPath(integration)}.`); + }, + ); }); diff --git a/src/lib/port-detection.ts b/src/lib/port-detection.ts index a8e2875f..6fc5b85f 100644 --- a/src/lib/port-detection.ts +++ b/src/lib/port-detection.ts @@ -36,6 +36,17 @@ export function getCallbackPath(integration: Integration): string { return settings.frameworks[settingsKey]?.callbackPath ?? DEFAULT_CALLBACK_PATH; } +/** The app's OAuth callback: the explicit override, else localhost on the detected port. */ +export function resolveRedirectUri( + integration: Integration, + { installDir, redirectUri }: { installDir: string; redirectUri?: string }, + port?: number, +): string { + return ( + redirectUri || `http://localhost:${port ?? detectPort(integration, installDir)}${getCallbackPath(integration)}` + ); +} + /** The route that starts sign-in, or undefined when the SDK guide does not fix one. */ export function getSignInPath(integration: Integration): string | undefined { return settings.frameworks[INTEGRATION_TO_SETTINGS_KEY[integration]]?.signInPath; diff --git a/src/lib/run-with-core.setup.spec.ts b/src/lib/run-with-core.setup.spec.ts index 6ef93bcd..1960b08e 100644 --- a/src/lib/run-with-core.setup.spec.ts +++ b/src/lib/run-with-core.setup.spec.ts @@ -249,9 +249,16 @@ describe('dashboard checklist reporting', () => { it('reports only the other two URLs when the environment step already reported the callback', async () => { const { emitter, events } = record(); const { initiateLoginUri: _omitted, ...withoutSignIn } = setup; - await reportAppUrlSetup(emitter, async () => ({ ...withoutSignIn, verified: true, callbackRegistered: true }), { - includeRedirect: false, - }); + await reportAppUrlSetup( + emitter, + async () => ({ + ...withoutSignIn, + initiateLoginReason: NO_SIGN_IN_ROUTE_REASON, + verified: true, + callbackRegistered: true, + }), + { includeRedirect: false }, + ); expect(events).toEqual([ 'app-urls:step initiate-login-uri started', 'app-urls:step sign-out-uri started', @@ -305,13 +312,15 @@ describe('application URLs for SDKs other than Next.js', () => { expect(result?.verified).toBe(true); }); - it('leaves out the initiate login URI when the app fails validation', async () => { + it("saves a server SDK's documented route without scanning its source", async () => { await configureOtherApplicationUrls( { options, integration: 'react-router', emitter: createInstallerEventEmitter() }, 'client_a', 'sk_test_a', ); - expect(vi.mocked(configureAuthkitApplication).mock.calls[0][0]).not.toHaveProperty('initiateLoginUri'); + expect(vi.mocked(configureAuthkitApplication).mock.calls[0][0].initiateLoginUri).toBe( + 'http://localhost:5173/login', + ); }); it('saves the Vite /login route once the app serves it', async () => { @@ -340,13 +349,26 @@ describe('application URLs for SDKs other than Next.js', () => { ); }); - it('does not point the dashboard at a Vite /login route the app lacks', async () => { - await configureOtherApplicationUrls( - { options, integration: 'react', emitter: createInstallerEventEmitter() }, + it('does not point the dashboard at a Vite /login route the app lacks, and says why', async () => { + const emitter = createInstallerEventEmitter(); + const events: string[] = []; + emitter.on('app-urls:step', ({ step, status, detail }) => events.push(`${step} ${status} ${detail ?? ''}`.trim())); + await configureOtherApplicationUrls({ options, integration: 'react', emitter }, 'client_a', 'sk_test_a'); + expect(vi.mocked(configureAuthkitApplication).mock.calls[0][0]).not.toHaveProperty('initiateLoginUri'); + expect(events).toContain('initiate-login-uri skipped The app has no /login route that starts sign-in.'); + }); + + it('shows an unusable callback URL in the checklist instead of dropping it', async () => { + const emitter = createInstallerEventEmitter(); + const events: string[] = []; + emitter.on('app-urls:step', ({ step, status }) => events.push(`${step} ${status}`)); + const result = await configureOtherApplicationUrls( + { options: { ...options, redirectUri: 'ftp://localhost/callback' }, integration: 'go', emitter }, 'client_a', - 'sk_test_a', ); - expect(vi.mocked(configureAuthkitApplication).mock.calls[0][0]).not.toHaveProperty('initiateLoginUri'); + expect(result).toBeUndefined(); + expect(events).toEqual(['initiate-login-uri skipped', 'sign-out-uri skipped']); + expect(configureAuthkitApplication).not.toHaveBeenCalled(); }); it('leaves the settings for the dashboard instead of failing the install', async () => { diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index 25e7bad5..0f206f5f 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -60,7 +60,8 @@ import { getNextJsRouter, assertNextjsSignInRouteAvailable, } from '../integrations/nextjs/utils.js'; -import { detectPort, getCallbackPath, getSignInPath } from './port-detection.js'; +import { detectPort, getSignInPath, resolveRedirectUri } from './port-detection.js'; +import { hasClientSignInRoute } from './validation/validator.js'; import { InstallDeclinedError } from './installer-errors.js'; import { writeEnvLocal } from './env-writer.js'; import { getRegistry } from './registry.js'; @@ -232,7 +233,7 @@ export async function configureInstallEnvironment( // Non-JavaScript agents write their own env files in the project's format. if (!isJavascript) return; - const redirectUri = installerOptions.redirectUri || `http://localhost:${port}${getCallbackPath(integration)}`; + const redirectUri = resolveRedirectUri(integration, installerOptions, port); const redirectUriKey = integration === 'nextjs' ? 'NEXT_PUBLIC_WORKOS_REDIRECT_URI' : 'WORKOS_REDIRECT_URI'; try { @@ -271,12 +272,11 @@ export async function reportAppUrlSetup( for (const id of includeRedirect ? (['redirect-uri', ...ids] as const) : ids) step(id, 'started'); const setup = await configure(); if (includeRedirect) step('redirect-uri', 'done'); - for (const id of ids) { - if (id === 'initiate-login-uri' && setup.initiateLoginUri === undefined) - step(id, 'skipped', NO_SIGN_IN_ROUTE_REASON); - else if (setup.verified) step(id, 'done'); - else step(id, 'skipped', setup.reason); - } + const settle = (id: SetupItemId) => (setup.verified ? step(id, 'done') : step(id, 'skipped', setup.reason)); + if (setup.initiateLoginUri === undefined) + step('initiate-login-uri', 'skipped', setup.initiateLoginReason ?? setup.reason); + else settle('initiate-login-uri'); + settle('sign-out-uri'); return setup; } @@ -293,19 +293,29 @@ export async function configureOtherApplicationUrls( ): Promise { const { options: installerOptions, integration } = context; if (!integration || integration === 'nextjs') return undefined; - const port = detectPort(integration, installerOptions.installDir); - const redirectUri = installerOptions.redirectUri || `http://localhost:${port}${getCallbackPath(integration)}`; - // Point the dashboard at the sign-in route only when the app checks out. - const validation = await validateInstallation(integration, installerOptions.installDir, { runBuild: false }); + let signInPath = getSignInPath(integration); + let initiateLoginReason = signInPath ? undefined : NO_SIGN_IN_ROUTE_REASON; + // A client-only route has no file the guide fixes, so save it only once the app serves it. + const clientOnly = (await getRegistry()).get(integration)?.config.environment.requiresApiKey === false; + if (signInPath && clientOnly && !(await hasClientSignInRoute(installerOptions.installDir, signInPath))) { + initiateLoginReason = `The app has no ${signInPath} route that starts sign-in.`; + signInPath = undefined; + } let setup: AuthkitApplicationSetup; try { - setup = buildApplicationSetup({ - clientId, - redirectUri, - homepageUrl: installerOptions.homepageUrl, - signInPath: validation.passed ? getSignInPath(integration) : undefined, - }); - } catch { + setup = { + ...buildApplicationSetup({ + clientId, + redirectUri: resolveRedirectUri(integration, installerOptions), + homepageUrl: installerOptions.homepageUrl, + signInPath, + }), + ...(initiateLoginReason ? { initiateLoginReason } : {}), + }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + for (const step of ['initiate-login-uri', 'sign-out-uri'] as const) + context.emitter.emit('app-urls:step', { step, status: 'skipped', detail }); return undefined; } return reportAppUrlSetup( @@ -497,9 +507,7 @@ export async function runWithCore(options: InstallerOptions): Promise { installerOptions.installDir, installerOptions.homepageUrl, ); - const expectedRedirectUri = - installerOptions.redirectUri || - `http://localhost:${detectPort(integration, installerOptions.installDir)}${getCallbackPath(integration)}`; + const expectedRedirectUri = resolveRedirectUri(integration, installerOptions); if (applicationSetup.redirectUri !== expectedRedirectUri) { throw new Error( 'The app callback URL changed during installation. Confirm it before configuring WorkOS.', diff --git a/src/lib/sign-in-route.ts b/src/lib/sign-in-route.ts index 900c4730..1742cdc1 100644 --- a/src/lib/sign-in-route.ts +++ b/src/lib/sign-in-route.ts @@ -1,10 +1,12 @@ -import type { Integration } from './constants.js'; +import type { FrameworkConfig } from './framework-config.js'; import { getSignInPath } from './port-detection.js'; -const CLIENT_ONLY_INTEGRATIONS = new Set(['react', 'vanilla-js']); - /** Pin the sign-in route the installer saves as the Initiate login URI (Next.js has its own section). */ -export function buildSignInSection(integration: Integration): string { +export function buildSignInSection({ + metadata, + environment, +}: Pick): string { + const { integration } = metadata; const signInPath = getSignInPath(integration); if (!signInPath || integration === 'nextjs') return ''; const lines = [ @@ -12,7 +14,8 @@ export function buildSignInSection(integration: Integration): string { '', `After you finish, the installer sets the WorkOS Initiate login URI to the app origin plus ${signInPath}. AuthKit sends users there when sign-in starts outside the app, such as from a password-reset email or an invitation. The route must exist at exactly ${signInPath}, must be public, and must start AuthKit sign-in through the SDK. It is never the callback route.`, ]; - if (CLIENT_ONLY_INTEGRATIONS.has(integration)) { + // Client-only SDKs are the ones that need no API key. + if (!environment.requiresApiKey) { lines.push( '', `This is a client-only app, so add a ${signInPath} client route that calls the SDK's signIn() as soon as the AuthKit client is ready, without a click (for example, in a useEffect). AuthKit keeps password-reset and invitation details through this redirect, so signIn() needs no extra arguments. If the app has a client router, register ${signInPath} in it. If it has none, check window.location.pathname at startup. Keep the existing sign-in button. Do not add a server route.`, diff --git a/src/lib/validation/validator.ts b/src/lib/validation/validator.ts index 407bdaf2..4d0e2b9e 100644 --- a/src/lib/validation/validator.ts +++ b/src/lib/validation/validator.ts @@ -258,12 +258,10 @@ export async function validateFrameworkSpecific(framework: string, projectDir: s } case 'react': await validateReactProviderWrapping(projectDir, issues); - await validateClientSignInRoute(framework, projectDir, issues); - await validateClientRedirectUri(projectDir, issues); + await validateClientOnlyApp(framework, projectDir, issues); break; case 'vanilla-js': - await validateClientSignInRoute(framework, projectDir, issues); - await validateClientRedirectUri(projectDir, issues); + await validateClientOnlyApp(framework, projectDir, issues); break; case 'react-router': await validateReactRouterRedirectUri(projectDir, issues); @@ -279,54 +277,61 @@ export async function validateFrameworkSpecific(framework: string, projectDir: s } /** - * Client-only apps have no route files to match, so look for the sign-in path - * in the source, or a page that serves it. The installer saves this route as - * the Initiate login URI. + * Client-only apps have no route files to match, so scan the source once for + * the sign-in route (saved as the Initiate login URI) and for `redirectUri`: + * the SDKs default to the page origin, but the installer registers + * WORKOS_REDIRECT_URI. */ -async function validateClientSignInRoute(framework: string, projectDir: string, issues: ValidationIssue[]) { - const signInPath = getSignInPath(framework as Parameters[0]); - if (!signInPath) return; +async function validateClientOnlyApp(framework: string, projectDir: string, issues: ValidationIssue[]) { + const signInPath = getSignInPath(framework); + const found = await clientSourceMatches(projectDir, signInPath ? [signInPath, 'redirectUri'] : ['redirectUri']); + if (signInPath && !found.has(signInPath) && !(await hasSignInPage(projectDir, signInPath))) + issues.push({ + type: 'file', + severity: 'error', + message: `No ${signInPath} route starts sign-in`, + hint: `Add a public ${signInPath} client route that calls the SDK's signIn() on load. The installer saves it as the Initiate login URI.`, + }); + if (!found.has('redirectUri')) + issues.push({ + type: 'pattern', + severity: 'error', + message: 'The AuthKit client does not set redirectUri', + hint: 'Pass WORKOS_REDIRECT_URI (with the build tool env prefix) as redirectUri to AuthKitProvider or createClient(). The SDK default, the page origin, is not registered.', + }); +} + +/** Whether a client-only app serves `signInPath`, from its source or a static page. */ +export async function hasClientSignInRoute(projectDir: string, signInPath: string): Promise { + return (await clientSourceMatches(projectDir, [signInPath])).size > 0 || hasSignInPage(projectDir, signInPath); +} + +async function hasSignInPage(projectDir: string, signInPath: string): Promise { const segment = signInPath.replace(/^\/|\/$/g, ''); const pages = await fg( [`${segment}.html`, `${segment}/index.html`, `public/${segment}.html`, `public/${segment}/index.html`], { cwd: projectDir }, ); - if (pages.length > 0 || (await clientSourceContains(projectDir, signInPath))) return; - issues.push({ - type: 'file', - severity: 'error', - message: `No ${signInPath} route starts sign-in`, - hint: `Add a public ${signInPath} client route that calls the SDK's signIn() on load. The installer saves it as the Initiate login URI.`, - }); + return pages.length > 0; } -/** - * The client SDKs default their redirect URI to the page origin, but the - * installer registers WORKOS_REDIRECT_URI, so the app must pass it. - */ -async function validateClientRedirectUri(projectDir: string, issues: ValidationIssue[]) { - if (await clientSourceContains(projectDir, 'redirectUri')) return; - issues.push({ - type: 'pattern', - severity: 'error', - message: 'The AuthKit client does not set redirectUri', - hint: 'Pass WORKOS_REDIRECT_URI (with the build tool env prefix) as redirectUri to AuthKitProvider or createClient(). The SDK default, the page origin, is not registered.', - }); -} - -async function clientSourceContains(projectDir: string, needle: string): Promise { +async function clientSourceMatches(projectDir: string, needles: string[]): Promise> { const sources = await fg(['**/*.{ts,tsx,js,jsx,mjs,html,htm}'], { cwd: projectDir, ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.*/**'], }); - for (const file of sources) { - try { - if ((await readFile(join(projectDir, file), 'utf-8')).includes(needle)) return true; - } catch { - // Unreadable file - keep looking - } - } - return false; + const found = new Set(); + await Promise.all( + sources.map(async (file) => { + try { + const content = await readFile(join(projectDir, file), 'utf-8'); + for (const needle of needles) if (content.includes(needle)) found.add(needle); + } catch { + // Unreadable file - skip it + } + }), + ); + return found; } /** From 9e4e2dafda845c19efbc6c7a5b5977451df25335 Mon Sep 17 00:00:00 2001 From: Jason Barry Date: Thu, 24 Sep 2026 16:58:09 -0700 Subject: [PATCH 06/34] fix(install): tighten client-only route and redirect URI checks Address review feedback on the client-only validation: - A link to /login no longer counts as the route. Require a route declaration (router path, createFileRoute, or a pathname check) or a static page that calls signIn(). - Check that the prefixed env var the client reads as redirectUri (for example VITE_WORKOS_REDIRECT_URI) is set, and flag an unprefixed one the build tool never exposes. The installer writes only the unprefixed WORKOS_REDIRECT_URI. - Read source files in bounded batches instead of all at once. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018EgeNr2FXXVcJ5o165U5Lx --- src/lib/agent-runner.spec.ts | 2 +- src/lib/run-with-core.setup.spec.ts | 11 ++++ src/lib/sign-in-route.ts | 2 +- src/lib/validation/validator.spec.ts | 52 +++++++++++++++ src/lib/validation/validator.ts | 98 ++++++++++++++++++++++------ 5 files changed, 143 insertions(+), 22 deletions(-) diff --git a/src/lib/agent-runner.spec.ts b/src/lib/agent-runner.spec.ts index fb488daa..d0c70b3e 100644 --- a/src/lib/agent-runner.spec.ts +++ b/src/lib/agent-runner.spec.ts @@ -125,7 +125,7 @@ describe('installer prompt', () => { expect(prompt).toContain('## Sign-in route (Initiate login URI)'); expect(prompt).toContain('the app origin plus /login'); expect(prompt).toContain('add a /login client route'); - expect(prompt).toContain('set redirectUri on AuthKitProvider or createClient()'); + expect(prompt).toContain('add it to .env.local under the build tool'); }); it("pins a server SDK to its guide's sign-in route without the client-only steps", async () => { diff --git a/src/lib/run-with-core.setup.spec.ts b/src/lib/run-with-core.setup.spec.ts index 1960b08e..fe49d223 100644 --- a/src/lib/run-with-core.setup.spec.ts +++ b/src/lib/run-with-core.setup.spec.ts @@ -349,6 +349,17 @@ describe('application URLs for SDKs other than Next.js', () => { ); }); + it('does not save a Vite /login route the app only links to', async () => { + await mkdir(join(directory, 'src'), { recursive: true }); + await writeFile(join(directory, 'src/App.tsx'), 'Sign in'); + await configureOtherApplicationUrls( + { options, integration: 'react', emitter: createInstallerEventEmitter() }, + 'client_a', + 'sk_test_a', + ); + expect(vi.mocked(configureAuthkitApplication).mock.calls[0][0]).not.toHaveProperty('initiateLoginUri'); + }); + it('does not point the dashboard at a Vite /login route the app lacks, and says why', async () => { const emitter = createInstallerEventEmitter(); const events: string[] = []; diff --git a/src/lib/sign-in-route.ts b/src/lib/sign-in-route.ts index 1742cdc1..3bf0ebc0 100644 --- a/src/lib/sign-in-route.ts +++ b/src/lib/sign-in-route.ts @@ -20,7 +20,7 @@ export function buildSignInSection({ '', `This is a client-only app, so add a ${signInPath} client route that calls the SDK's signIn() as soon as the AuthKit client is ready, without a click (for example, in a useEffect). AuthKit keeps password-reset and invitation details through this redirect, so signIn() needs no extra arguments. If the app has a client router, register ${signInPath} in it. If it has none, check window.location.pathname at startup. Keep the existing sign-in button. Do not add a server route.`, '', - "The installer registers WORKOS_REDIRECT_URI as the app's Redirect URI. The SDK defaults to the page origin instead, so pass that value explicitly: expose it with the build tool's env prefix (for example, VITE_WORKOS_REDIRECT_URI) and set redirectUri on AuthKitProvider or createClient().", + "The installer registers WORKOS_REDIRECT_URI as the app's Redirect URI. The SDK defaults to the page origin instead, so pass that value explicitly: add it to .env.local under the build tool's env prefix (for example, VITE_WORKOS_REDIRECT_URI), read that variable in code, and set it as redirectUri on AuthKitProvider or createClient().", ); } return `${lines.join('\n')}\n\n`; diff --git a/src/lib/validation/validator.spec.ts b/src/lib/validation/validator.spec.ts index 9607e23b..83d78755 100644 --- a/src/lib/validation/validator.spec.ts +++ b/src/lib/validation/validator.spec.ts @@ -790,6 +790,57 @@ describe('validateInstallation', () => { expect(signInIssue(result.issues)).toBeDefined(); }); + it('does not treat a link to /login as the route', async () => { + mkdirSync(join(testDir, 'src'), { recursive: true }); + writeFileSync(join(testDir, 'src', 'App.tsx'), 'Sign in'); + + const result = await validateInstallation('react', testDir, { runBuild: false }); + + expect(signInIssue(result.issues)?.severity).toBe('error'); + }); + + it('does not accept a /login page that never starts sign-in', async () => { + mkdirSync(join(testDir, 'login'), { recursive: true }); + writeFileSync(join(testDir, 'login', 'index.html'), '

Log in

'); + + const result = await validateInstallation('vanilla-js', testDir, { runBuild: false }); + + expect(signInIssue(result.issues)).toBeDefined(); + }); + + it('fails when the prefixed redirect URI the client reads is not set', async () => { + mkdirSync(join(testDir, 'src'), { recursive: true }); + writeFileSync( + join(testDir, 'src', 'main.tsx'), + '', + ); + writeFileSync(join(testDir, '.env.local'), 'WORKOS_REDIRECT_URI=http://localhost:5173/callback\n'); + + const result = await validateInstallation('react', testDir, { runBuild: false }); + + expect(result.issues).toContainEqual( + expect.objectContaining({ + severity: 'error', + message: 'Missing environment variable: VITE_WORKOS_REDIRECT_URI', + }), + ); + }); + + it('fails when the client reads an unprefixed redirect URI Vite never exposes', async () => { + mkdirSync(join(testDir, 'src'), { recursive: true }); + writeFileSync( + join(testDir, 'src', 'main.ts'), + 'createClient(clientId, { redirectUri: import.meta.env.WORKOS_REDIRECT_URI });', + ); + writeFileSync(join(testDir, '.env.local'), 'WORKOS_REDIRECT_URI=http://localhost:5173/callback\n'); + + const result = await validateInstallation('vanilla-js', testDir, { runBuild: false }); + + expect(result.issues).toContainEqual( + expect.objectContaining({ message: 'WORKOS_REDIRECT_URI is not exposed to client code' }), + ); + }); + it('fails a client app that leaves redirectUri at the SDK default', async () => { mkdirSync(join(testDir, 'src'), { recursive: true }); writeFileSync( @@ -808,6 +859,7 @@ describe('validateInstallation', () => { join(testDir, 'src', 'main.ts'), 'createClient(clientId, { redirectUri: import.meta.env.VITE_WORKOS_REDIRECT_URI });', ); + writeFileSync(join(testDir, '.env.local'), 'VITE_WORKOS_REDIRECT_URI=http://localhost:5173/callback\n'); const result = await validateInstallation('vanilla-js', testDir, { runBuild: false }); diff --git a/src/lib/validation/validator.ts b/src/lib/validation/validator.ts index 4d0e2b9e..b4ecc4cb 100644 --- a/src/lib/validation/validator.ts +++ b/src/lib/validation/validator.ts @@ -284,54 +284,112 @@ export async function validateFrameworkSpecific(framework: string, projectDir: s */ async function validateClientOnlyApp(framework: string, projectDir: string, issues: ValidationIssue[]) { const signInPath = getSignInPath(framework); - const found = await clientSourceMatches(projectDir, signInPath ? [signInPath, 'redirectUri'] : ['redirectUri']); - if (signInPath && !found.has(signInPath) && !(await hasSignInPage(projectDir, signInPath))) + const route = signInPath ? signInRouteDeclaration(signInPath) : undefined; + let hasRoute = false; + let setsRedirectUri = false; + const redirectEnvNames = new Set(); + await scanClientSource(projectDir, (content) => { + hasRoute ||= !!route?.test(content); + setsRedirectUri ||= content.includes('redirectUri'); + for (const match of content.matchAll(REDIRECT_ENV_REFERENCE)) redirectEnvNames.add(match[1]); + }); + if (signInPath && !hasRoute && !(await hasSignInPage(projectDir, signInPath))) issues.push({ type: 'file', severity: 'error', message: `No ${signInPath} route starts sign-in`, - hint: `Add a public ${signInPath} client route that calls the SDK's signIn() on load. The installer saves it as the Initiate login URI.`, + hint: `Register a public ${signInPath} client route that calls the SDK's signIn() on load. A link to ${signInPath} is not a route. The installer saves it as the Initiate login URI.`, }); - if (!found.has('redirectUri')) + if (!setsRedirectUri) issues.push({ type: 'pattern', severity: 'error', message: 'The AuthKit client does not set redirectUri', hint: 'Pass WORKOS_REDIRECT_URI (with the build tool env prefix) as redirectUri to AuthKitProvider or createClient(). The SDK default, the page origin, is not registered.', }); + if (redirectEnvNames.size === 0) return; + const envContent = await readClientEnvFiles(projectDir); + for (const name of redirectEnvNames) { + if (!/^(VITE_|REACT_APP_)/.test(name)) + issues.push({ + type: 'env', + severity: 'error', + message: `${name} is not exposed to client code`, + hint: 'Use the build tool prefix (for example, VITE_WORKOS_REDIRECT_URI) in both the code and .env.local.', + }); + else if (!new RegExp(`^${name}=.+`, 'm').test(envContent)) + issues.push({ + type: 'env', + severity: 'error', + message: `Missing environment variable: ${name}`, + hint: `The client reads ${name} as its redirect URI. Add ${name} to .env.local with the WORKOS_REDIRECT_URI value.`, + }); + } +} + +/** A client-side env var the app reads its redirect URI from, e.g. import.meta.env.VITE_WORKOS_REDIRECT_URI. */ +const REDIRECT_ENV_REFERENCE = /(?:import\.meta\.env|process\.env)\.(\w*WORKOS_REDIRECT_URI)\b/g; + +/** Vite and CRA read these in development. */ +const CLIENT_ENV_FILES = ['.env', '.env.local', '.env.development', '.env.development.local']; + +async function readClientEnvFiles(projectDir: string): Promise { + const contents = await Promise.all( + CLIENT_ENV_FILES.map((file) => readFile(join(projectDir, file), 'utf-8').catch(() => '')), + ); + return contents.join('\n'); } -/** Whether a client-only app serves `signInPath`, from its source or a static page. */ +/** + * Matches code that serves `signInPath` as a route, not a link to it: router + * config (`path="/login"`, `path: '/login'`, `createFileRoute('/login')`) or a + * pathname check (`pathname === '/login'`, `case '/login'`). + */ +function signInRouteDeclaration(signInPath: string): RegExp { + const path = signInPath.replace(/\/$/, '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const quoted = `['"\`]${path}/?['"\`]`; + return new RegExp( + `(?:\\bpath\\s*[:=]\\s*\\{?\\s*|pathname\\s*={2,3}\\s*|\\bcase\\s+|\\bcreate(?:File)?Route\\(\\s*)${quoted}`, + ); +} + +/** Whether a client-only app serves `signInPath`, from a route in its source or a static page. */ export async function hasClientSignInRoute(projectDir: string, signInPath: string): Promise { - return (await clientSourceMatches(projectDir, [signInPath])).size > 0 || hasSignInPage(projectDir, signInPath); + const route = signInRouteDeclaration(signInPath); + let found = false; + await scanClientSource(projectDir, (content) => { + found ||= route.test(content); + }); + return found || hasSignInPage(projectDir, signInPath); } +/** A static page at the sign-in path, e.g. login/index.html, that starts sign-in. */ async function hasSignInPage(projectDir: string, signInPath: string): Promise { const segment = signInPath.replace(/^\/|\/$/g, ''); const pages = await fg( [`${segment}.html`, `${segment}/index.html`, `public/${segment}.html`, `public/${segment}/index.html`], { cwd: projectDir }, ); - return pages.length > 0; + for (const page of pages) { + if ((await readFile(join(projectDir, page), 'utf-8').catch(() => '')).includes('signIn')) return true; + } + return false; } -async function clientSourceMatches(projectDir: string, needles: string[]): Promise> { +const SCAN_CONCURRENCY = 32; + +/** Read the app's own source files, a bounded batch at a time. */ +async function scanClientSource(projectDir: string, visit: (content: string) => void): Promise { const sources = await fg(['**/*.{ts,tsx,js,jsx,mjs,html,htm}'], { cwd: projectDir, ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.*/**'], }); - const found = new Set(); - await Promise.all( - sources.map(async (file) => { - try { - const content = await readFile(join(projectDir, file), 'utf-8'); - for (const needle of needles) if (content.includes(needle)) found.add(needle); - } catch { - // Unreadable file - skip it - } - }), - ); - return found; + for (let i = 0; i < sources.length; i += SCAN_CONCURRENCY) { + const batch = await Promise.all( + sources.slice(i, i + SCAN_CONCURRENCY).map((file) => readFile(join(projectDir, file), 'utf-8').catch(() => '')), + ); + batch.forEach(visit); + } } /** From 42b851ba19ad148b2fe96b3294ec0e1ff4c60117 Mon Sep 17 00:00:00 2001 From: Jason Barry Date: Thu, 24 Sep 2026 17:00:25 -0700 Subject: [PATCH 07/34] refactor(install): write client env vars under the bundler prefix For client-only apps, the installer now writes VITE_ (or REACT_APP_ for Create React App) WORKOS_CLIENT_ID and WORKOS_REDIRECT_URI itself, instead of asking the agent to copy them and validating the copy. The validator only checks that the client reads the variable the installer wrote. Also share one route check between validation and the save step, build the route pattern from a named list, and read static pages in parallel. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018EgeNr2FXXVcJ5o165U5Lx --- src/lib/agent-runner.spec.ts | 2 +- src/lib/port-detection.ts | 17 ++++ src/lib/run-with-core.setup.spec.ts | 23 ++++++ src/lib/run-with-core.ts | 12 ++- src/lib/sign-in-route.ts | 2 +- src/lib/validation/validator.spec.ts | 52 +++++------- src/lib/validation/validator.ts | 119 ++++++++++----------------- 7 files changed, 117 insertions(+), 110 deletions(-) diff --git a/src/lib/agent-runner.spec.ts b/src/lib/agent-runner.spec.ts index d0c70b3e..e4fffc19 100644 --- a/src/lib/agent-runner.spec.ts +++ b/src/lib/agent-runner.spec.ts @@ -125,7 +125,7 @@ describe('installer prompt', () => { expect(prompt).toContain('## Sign-in route (Initiate login URI)'); expect(prompt).toContain('the app origin plus /login'); expect(prompt).toContain('add a /login client route'); - expect(prompt).toContain('add it to .env.local under the build tool'); + expect(prompt).toContain('VITE_WORKOS_REDIRECT_URI for Vite'); }); it("pins a server SDK to its guide's sign-in route without the client-only steps", async () => { diff --git a/src/lib/port-detection.ts b/src/lib/port-detection.ts index 6fc5b85f..e511cf06 100644 --- a/src/lib/port-detection.ts +++ b/src/lib/port-detection.ts @@ -47,6 +47,23 @@ export function resolveRedirectUri( ); } +/** The env prefix a client bundler exposes to browser code: Vite, then Create React App. */ +export function getClientEnvPrefix(installDir: string): 'VITE_' | 'REACT_APP_' | undefined { + let deps: Record = {}; + try { + const pkg = JSON.parse(fs.readFileSync(join(installDir, 'package.json'), 'utf-8')); + deps = { ...pkg.dependencies, ...pkg.devDependencies }; + } catch { + // No readable package.json - rely on the Vite config check + } + if ( + deps.vite || + ['vite.config.ts', 'vite.config.js', 'vite.config.mjs'].some((f) => fs.existsSync(join(installDir, f))) + ) + return 'VITE_'; + return deps['react-scripts'] ? 'REACT_APP_' : undefined; +} + /** The route that starts sign-in, or undefined when the SDK guide does not fix one. */ export function getSignInPath(integration: Integration): string | undefined { return settings.frameworks[INTEGRATION_TO_SETTINGS_KEY[integration]]?.signInPath; diff --git a/src/lib/run-with-core.setup.spec.ts b/src/lib/run-with-core.setup.spec.ts index fe49d223..cf0db87e 100644 --- a/src/lib/run-with-core.setup.spec.ts +++ b/src/lib/run-with-core.setup.spec.ts @@ -178,6 +178,29 @@ describe('dashboard checklist reporting', () => { await expect(readFile(join(directory, '.env.local'))).rejects.toMatchObject({ code: 'ENOENT' }); }); + it('writes the client ID and callback under the Vite prefix for a client-only app', async () => { + await writeFile(join(directory, 'package.json'), JSON.stringify({ devDependencies: { vite: '^6.0.0' } })); + await configureInstallEnvironment({ + options, + integration: 'react', + credentials: { clientId: 'client_a' }, + }); + const env = await readFile(join(directory, '.env.local'), 'utf8'); + expect(env).toContain('VITE_WORKOS_CLIENT_ID=client_a'); + expect(env).toContain('VITE_WORKOS_REDIRECT_URI=http://localhost:5173/callback'); + expect(env).toContain('WORKOS_REDIRECT_URI=http://localhost:5173/callback'); + }); + + it('writes no prefixed vars for a server SDK', async () => { + await writeFile(join(directory, 'package.json'), JSON.stringify({ devDependencies: { vite: '^6.0.0' } })); + await configureInstallEnvironment({ + options, + integration: 'react-router', + credentials: { clientId: 'client_a' }, + }); + expect(await readFile(join(directory, '.env.local'), 'utf8')).not.toContain('VITE_WORKOS'); + }); + it.each(['react', 'vanilla-js'] as const)( 'registers the Vite callback and CORS origin for the client-only %s SDK', async (integration) => { diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index 0f206f5f..5c314299 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -60,7 +60,7 @@ import { getNextJsRouter, assertNextjsSignInRouteAvailable, } from '../integrations/nextjs/utils.js'; -import { detectPort, getSignInPath, resolveRedirectUri } from './port-detection.js'; +import { detectPort, getClientEnvPrefix, getSignInPath, resolveRedirectUri } from './port-detection.js'; import { hasClientSignInRoute } from './validation/validator.js'; import { InstallDeclinedError } from './installer-errors.js'; import { writeEnvLocal } from './env-writer.js'; @@ -236,11 +236,21 @@ export async function configureInstallEnvironment( const redirectUri = resolveRedirectUri(integration, installerOptions, port); const redirectUriKey = integration === 'nextjs' ? 'NEXT_PUBLIC_WORKOS_REDIRECT_URI' : 'WORKOS_REDIRECT_URI'; + // Client bundlers expose only prefixed vars to browser code. + const clientPrefix = mod.config.environment.requiresApiKey + ? undefined + : getClientEnvPrefix(installerOptions.installDir); try { writeEnvLocal(installerOptions.installDir, { ...(credentials.apiKey ? { WORKOS_API_KEY: credentials.apiKey } : {}), WORKOS_CLIENT_ID: credentials.clientId, [redirectUriKey]: redirectUri, + ...(clientPrefix + ? { + [`${clientPrefix}WORKOS_CLIENT_ID`]: credentials.clientId, + [`${clientPrefix}WORKOS_REDIRECT_URI`]: redirectUri, + } + : {}), }); } catch (error) { step('env-vars', 'failed', error instanceof Error ? error.message : String(error)); diff --git a/src/lib/sign-in-route.ts b/src/lib/sign-in-route.ts index 3bf0ebc0..13c42489 100644 --- a/src/lib/sign-in-route.ts +++ b/src/lib/sign-in-route.ts @@ -20,7 +20,7 @@ export function buildSignInSection({ '', `This is a client-only app, so add a ${signInPath} client route that calls the SDK's signIn() as soon as the AuthKit client is ready, without a click (for example, in a useEffect). AuthKit keeps password-reset and invitation details through this redirect, so signIn() needs no extra arguments. If the app has a client router, register ${signInPath} in it. If it has none, check window.location.pathname at startup. Keep the existing sign-in button. Do not add a server route.`, '', - "The installer registers WORKOS_REDIRECT_URI as the app's Redirect URI. The SDK defaults to the page origin instead, so pass that value explicitly: add it to .env.local under the build tool's env prefix (for example, VITE_WORKOS_REDIRECT_URI), read that variable in code, and set it as redirectUri on AuthKitProvider or createClient().", + "The installer registers WORKOS_REDIRECT_URI as the app's Redirect URI. The SDK defaults to the page origin instead, so pass that value explicitly. The installer also writes it to .env.local under the bundler's env prefix (VITE_WORKOS_REDIRECT_URI for Vite, REACT_APP_WORKOS_REDIRECT_URI for Create React App), with the client ID beside it. Read that variable and set it as redirectUri on AuthKitProvider or createClient().", ); } return `${lines.join('\n')}\n\n`; diff --git a/src/lib/validation/validator.spec.ts b/src/lib/validation/validator.spec.ts index 83d78755..55af24e4 100644 --- a/src/lib/validation/validator.spec.ts +++ b/src/lib/validation/validator.spec.ts @@ -808,38 +808,26 @@ describe('validateInstallation', () => { expect(signInIssue(result.issues)).toBeDefined(); }); - it('fails when the prefixed redirect URI the client reads is not set', async () => { - mkdirSync(join(testDir, 'src'), { recursive: true }); - writeFileSync( - join(testDir, 'src', 'main.tsx'), - '', - ); - writeFileSync(join(testDir, '.env.local'), 'WORKOS_REDIRECT_URI=http://localhost:5173/callback\n'); - - const result = await validateInstallation('react', testDir, { runBuild: false }); - - expect(result.issues).toContainEqual( - expect.objectContaining({ - severity: 'error', - message: 'Missing environment variable: VITE_WORKOS_REDIRECT_URI', - }), - ); - }); - - it('fails when the client reads an unprefixed redirect URI Vite never exposes', async () => { - mkdirSync(join(testDir, 'src'), { recursive: true }); - writeFileSync( - join(testDir, 'src', 'main.ts'), - 'createClient(clientId, { redirectUri: import.meta.env.WORKOS_REDIRECT_URI });', - ); - writeFileSync(join(testDir, '.env.local'), 'WORKOS_REDIRECT_URI=http://localhost:5173/callback\n'); - - const result = await validateInstallation('vanilla-js', testDir, { runBuild: false }); - - expect(result.issues).toContainEqual( - expect.objectContaining({ message: 'WORKOS_REDIRECT_URI is not exposed to client code' }), - ); - }); + it.each(['WORKOS_REDIRECT_URI', 'REACT_APP_WORKOS_REDIRECT_URI'])( + 'fails when a Vite client reads %s instead of the var the installer writes', + async (name) => { + writeFileSync(join(testDir, 'package.json'), JSON.stringify({ devDependencies: { vite: '^6.0.0' } })); + mkdirSync(join(testDir, 'src'), { recursive: true }); + writeFileSync( + join(testDir, 'src', 'main.ts'), + `createClient(clientId, { redirectUri: import.meta.env.${name} });`, + ); + + const result = await validateInstallation('vanilla-js', testDir, { runBuild: false }); + + expect(result.issues).toContainEqual( + expect.objectContaining({ + severity: 'error', + message: `The client reads ${name}, but the installer writes VITE_WORKOS_REDIRECT_URI`, + }), + ); + }, + ); it('fails a client app that leaves redirectUri at the SDK default', async () => { mkdirSync(join(testDir, 'src'), { recursive: true }); diff --git a/src/lib/validation/validator.ts b/src/lib/validation/validator.ts index b4ecc4cb..5dc6e5eb 100644 --- a/src/lib/validation/validator.ts +++ b/src/lib/validation/validator.ts @@ -4,7 +4,7 @@ import { join } from 'path'; import fg from 'fast-glob'; import type { ValidationResult, ValidationRules, ValidationIssue } from './types.js'; import { runBuildValidation } from './build-validator.js'; -import { detectPort, getSignInPath } from '../port-detection.js'; +import { detectPort, getClientEnvPrefix, getSignInPath } from '../port-detection.js'; import { nextjsRoutePath, findNextjsSignInPage } from '../../integrations/nextjs/utils.js'; import nextjsRules from './rules/nextjs.json' with { type: 'json' }; import reactRouterRules from './rules/react-router.json' with { type: 'json' }; @@ -277,119 +277,88 @@ export async function validateFrameworkSpecific(framework: string, projectDir: s } /** - * Client-only apps have no route files to match, so scan the source once for + * Client-only apps have no route files to match, so check their source for * the sign-in route (saved as the Initiate login URI) and for `redirectUri`: * the SDKs default to the page origin, but the installer registers - * WORKOS_REDIRECT_URI. + * WORKOS_REDIRECT_URI and writes it under the bundler's env prefix. */ async function validateClientOnlyApp(framework: string, projectDir: string, issues: ValidationIssue[]) { const signInPath = getSignInPath(framework); - const route = signInPath ? signInRouteDeclaration(signInPath) : undefined; - let hasRoute = false; - let setsRedirectUri = false; - const redirectEnvNames = new Set(); - await scanClientSource(projectDir, (content) => { - hasRoute ||= !!route?.test(content); - setsRedirectUri ||= content.includes('redirectUri'); - for (const match of content.matchAll(REDIRECT_ENV_REFERENCE)) redirectEnvNames.add(match[1]); - }); - if (signInPath && !hasRoute && !(await hasSignInPage(projectDir, signInPath))) + const sources = await readClientSource(projectDir); + if (signInPath && !(await servesSignInRoute(projectDir, sources, signInPath))) issues.push({ type: 'file', severity: 'error', message: `No ${signInPath} route starts sign-in`, hint: `Register a public ${signInPath} client route that calls the SDK's signIn() on load. A link to ${signInPath} is not a route. The installer saves it as the Initiate login URI.`, }); - if (!setsRedirectUri) + if (!sources.some((content) => content.includes('redirectUri'))) issues.push({ type: 'pattern', severity: 'error', message: 'The AuthKit client does not set redirectUri', - hint: 'Pass WORKOS_REDIRECT_URI (with the build tool env prefix) as redirectUri to AuthKitProvider or createClient(). The SDK default, the page origin, is not registered.', + hint: 'Pass the installed WORKOS_REDIRECT_URI env var as redirectUri to AuthKitProvider or createClient(). The SDK default, the page origin, is not registered.', + }); + const prefix = getClientEnvPrefix(projectDir); + if (!prefix) return; + const written = `${prefix}WORKOS_REDIRECT_URI`; + const read = new Set(sources.flatMap((content) => [...content.matchAll(REDIRECT_ENV_REFERENCE)].map((m) => m[1]))); + read.delete(written); + for (const name of read) + issues.push({ + type: 'env', + severity: 'error', + message: `The client reads ${name}, but the installer writes ${written}`, + hint: `Read ${written} as the redirect URI.`, }); - if (redirectEnvNames.size === 0) return; - const envContent = await readClientEnvFiles(projectDir); - for (const name of redirectEnvNames) { - if (!/^(VITE_|REACT_APP_)/.test(name)) - issues.push({ - type: 'env', - severity: 'error', - message: `${name} is not exposed to client code`, - hint: 'Use the build tool prefix (for example, VITE_WORKOS_REDIRECT_URI) in both the code and .env.local.', - }); - else if (!new RegExp(`^${name}=.+`, 'm').test(envContent)) - issues.push({ - type: 'env', - severity: 'error', - message: `Missing environment variable: ${name}`, - hint: `The client reads ${name} as its redirect URI. Add ${name} to .env.local with the WORKOS_REDIRECT_URI value.`, - }); - } } /** A client-side env var the app reads its redirect URI from, e.g. import.meta.env.VITE_WORKOS_REDIRECT_URI. */ const REDIRECT_ENV_REFERENCE = /(?:import\.meta\.env|process\.env)\.(\w*WORKOS_REDIRECT_URI)\b/g; -/** Vite and CRA read these in development. */ -const CLIENT_ENV_FILES = ['.env', '.env.local', '.env.development', '.env.development.local']; - -async function readClientEnvFiles(projectDir: string): Promise { - const contents = await Promise.all( - CLIENT_ENV_FILES.map((file) => readFile(join(projectDir, file), 'utf-8').catch(() => '')), - ); - return contents.join('\n'); -} - -/** - * Matches code that serves `signInPath` as a route, not a link to it: router - * config (`path="/login"`, `path: '/login'`, `createFileRoute('/login')`) or a - * pathname check (`pathname === '/login'`, `case '/login'`). - */ -function signInRouteDeclaration(signInPath: string): RegExp { - const path = signInPath.replace(/\/$/, '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const quoted = `['"\`]${path}/?['"\`]`; - return new RegExp( - `(?:\\bpath\\s*[:=]\\s*\\{?\\s*|pathname\\s*={2,3}\\s*|\\bcase\\s+|\\bcreate(?:File)?Route\\(\\s*)${quoted}`, - ); -} +/** Code that serves a route rather than linking to it: router config or a pathname check. */ +const ROUTE_DECLARATIONS = [ + String.raw`\bpath\s*[:=]\s*\{?\s*`, + String.raw`pathname\s*===?\s*`, + String.raw`\bcase\s+`, + String.raw`\bcreate(?:File)?Route\(\s*`, +]; /** Whether a client-only app serves `signInPath`, from a route in its source or a static page. */ export async function hasClientSignInRoute(projectDir: string, signInPath: string): Promise { - const route = signInRouteDeclaration(signInPath); - let found = false; - await scanClientSource(projectDir, (content) => { - found ||= route.test(content); - }); - return found || hasSignInPage(projectDir, signInPath); + return servesSignInRoute(projectDir, await readClientSource(projectDir), signInPath); } -/** A static page at the sign-in path, e.g. login/index.html, that starts sign-in. */ -async function hasSignInPage(projectDir: string, signInPath: string): Promise { +async function servesSignInRoute(projectDir: string, sources: string[], signInPath: string): Promise { + const route = new RegExp(`(?:${ROUTE_DECLARATIONS.join('|')})['"\`]${signInPath.replace(/\/$/, '')}/?['"\`]`); + if (sources.some((content) => route.test(content))) return true; + // A static page at the path, e.g. login/index.html, that starts sign-in. const segment = signInPath.replace(/^\/|\/$/g, ''); const pages = await fg( [`${segment}.html`, `${segment}/index.html`, `public/${segment}.html`, `public/${segment}/index.html`], { cwd: projectDir }, ); - for (const page of pages) { - if ((await readFile(join(projectDir, page), 'utf-8').catch(() => '')).includes('signIn')) return true; - } - return false; + return (await Promise.all(pages.map((page) => readOrEmpty(join(projectDir, page))))).some((content) => + content.includes('signIn'), + ); } +const readOrEmpty = (path: string) => readFile(path, 'utf-8').catch(() => ''); + const SCAN_CONCURRENCY = 32; -/** Read the app's own source files, a bounded batch at a time. */ -async function scanClientSource(projectDir: string, visit: (content: string) => void): Promise { - const sources = await fg(['**/*.{ts,tsx,js,jsx,mjs,html,htm}'], { +/** The app's own source files, read a bounded batch at a time. */ +async function readClientSource(projectDir: string): Promise { + const files = await fg(['**/*.{ts,tsx,js,jsx,mjs,html,htm}'], { cwd: projectDir, ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.*/**'], }); - for (let i = 0; i < sources.length; i += SCAN_CONCURRENCY) { - const batch = await Promise.all( - sources.slice(i, i + SCAN_CONCURRENCY).map((file) => readFile(join(projectDir, file), 'utf-8').catch(() => '')), + const contents: string[] = []; + for (let i = 0; i < files.length; i += SCAN_CONCURRENCY) + contents.push( + ...(await Promise.all(files.slice(i, i + SCAN_CONCURRENCY).map((file) => readOrEmpty(join(projectDir, file))))), ); - batch.forEach(visit); - } + return contents; } /** From e5d47f02e62cd024d4a382d6655b24cd177b5024 Mon Sep 17 00:00:00 2001 From: Jason Barry Date: Thu, 24 Sep 2026 17:07:35 -0700 Subject: [PATCH 08/34] fix(install): check only browser code for the client redirect URI var A Vite app may read the unprefixed WORKOS_REDIRECT_URI in server-side files such as vite.config.ts. Check Vite browser code through import.meta.env, and Create React App through process.env in src/, so those files no longer fail validation. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018EgeNr2FXXVcJ5o165U5Lx --- src/lib/validation/validator.spec.ts | 31 ++++++++++++++++++++++++ src/lib/validation/validator.ts | 35 +++++++++++++++++++--------- 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/src/lib/validation/validator.spec.ts b/src/lib/validation/validator.spec.ts index 55af24e4..8ab7ed27 100644 --- a/src/lib/validation/validator.spec.ts +++ b/src/lib/validation/validator.spec.ts @@ -829,6 +829,37 @@ describe('validateInstallation', () => { }, ); + it('lets vite.config.ts read the unprefixed redirect URI', async () => { + writeFileSync(join(testDir, 'package.json'), JSON.stringify({ devDependencies: { vite: '^6.0.0' } })); + writeFileSync(join(testDir, 'vite.config.ts'), 'const callback = process.env.WORKOS_REDIRECT_URI;'); + mkdirSync(join(testDir, 'src'), { recursive: true }); + writeFileSync( + join(testDir, 'src', 'main.ts'), + 'createClient(clientId, { redirectUri: import.meta.env.VITE_WORKOS_REDIRECT_URI });', + ); + + const result = await validateInstallation('vanilla-js', testDir, { runBuild: false }); + + expect(result.issues.some((i) => i.message.startsWith('The client reads'))).toBe(false); + }); + + it('flags a Create React App client that reads the unprefixed redirect URI', async () => { + writeFileSync(join(testDir, 'package.json'), JSON.stringify({ dependencies: { 'react-scripts': '5.0.1' } })); + mkdirSync(join(testDir, 'src'), { recursive: true }); + writeFileSync( + join(testDir, 'src', 'index.tsx'), + '', + ); + + const result = await validateInstallation('react', testDir, { runBuild: false }); + + expect(result.issues).toContainEqual( + expect.objectContaining({ + message: 'The client reads WORKOS_REDIRECT_URI, but the installer writes REACT_APP_WORKOS_REDIRECT_URI', + }), + ); + }); + it('fails a client app that leaves redirectUri at the SDK default', async () => { mkdirSync(join(testDir, 'src'), { recursive: true }); writeFileSync( diff --git a/src/lib/validation/validator.ts b/src/lib/validation/validator.ts index 5dc6e5eb..d252a5db 100644 --- a/src/lib/validation/validator.ts +++ b/src/lib/validation/validator.ts @@ -292,7 +292,7 @@ async function validateClientOnlyApp(framework: string, projectDir: string, issu message: `No ${signInPath} route starts sign-in`, hint: `Register a public ${signInPath} client route that calls the SDK's signIn() on load. A link to ${signInPath} is not a route. The installer saves it as the Initiate login URI.`, }); - if (!sources.some((content) => content.includes('redirectUri'))) + if (!sources.some(({ content }) => content.includes('redirectUri'))) issues.push({ type: 'pattern', severity: 'error', @@ -302,7 +302,10 @@ async function validateClientOnlyApp(framework: string, projectDir: string, issu const prefix = getClientEnvPrefix(projectDir); if (!prefix) return; const written = `${prefix}WORKOS_REDIRECT_URI`; - const read = new Set(sources.flatMap((content) => [...content.matchAll(REDIRECT_ENV_REFERENCE)].map((m) => m[1]))); + // Only browser code must read the prefixed var; vite.config.ts or a script may read the unprefixed one. + const browserSources = prefix === 'VITE_' ? sources : sources.filter(({ file }) => file.startsWith('src/')); + const pattern = prefix === 'VITE_' ? VITE_REDIRECT_ENV_REFERENCE : CRA_REDIRECT_ENV_REFERENCE; + const read = new Set(browserSources.flatMap(({ content }) => [...content.matchAll(pattern)].map((m) => m[1]))); read.delete(written); for (const name of read) issues.push({ @@ -313,8 +316,9 @@ async function validateClientOnlyApp(framework: string, projectDir: string, issu }); } -/** A client-side env var the app reads its redirect URI from, e.g. import.meta.env.VITE_WORKOS_REDIRECT_URI. */ -const REDIRECT_ENV_REFERENCE = /(?:import\.meta\.env|process\.env)\.(\w*WORKOS_REDIRECT_URI)\b/g; +/** How browser code reads its redirect URI: Vite through import.meta.env, Create React App through process.env in src/. */ +const VITE_REDIRECT_ENV_REFERENCE = /import\.meta\.env\.(\w*WORKOS_REDIRECT_URI)\b/g; +const CRA_REDIRECT_ENV_REFERENCE = /process\.env\.(\w*WORKOS_REDIRECT_URI)\b/g; /** Code that serves a route rather than linking to it: router config or a pathname check. */ const ROUTE_DECLARATIONS = [ @@ -329,9 +333,9 @@ export async function hasClientSignInRoute(projectDir: string, signInPath: strin return servesSignInRoute(projectDir, await readClientSource(projectDir), signInPath); } -async function servesSignInRoute(projectDir: string, sources: string[], signInPath: string): Promise { +async function servesSignInRoute(projectDir: string, sources: ClientSource[], signInPath: string): Promise { const route = new RegExp(`(?:${ROUTE_DECLARATIONS.join('|')})['"\`]${signInPath.replace(/\/$/, '')}/?['"\`]`); - if (sources.some((content) => route.test(content))) return true; + if (sources.some(({ content }) => route.test(content))) return true; // A static page at the path, e.g. login/index.html, that starts sign-in. const segment = signInPath.replace(/^\/|\/$/g, ''); const pages = await fg( @@ -347,18 +351,27 @@ const readOrEmpty = (path: string) => readFile(path, 'utf-8').catch(() => ''); const SCAN_CONCURRENCY = 32; +interface ClientSource { + file: string; + content: string; +} + /** The app's own source files, read a bounded batch at a time. */ -async function readClientSource(projectDir: string): Promise { +async function readClientSource(projectDir: string): Promise { const files = await fg(['**/*.{ts,tsx,js,jsx,mjs,html,htm}'], { cwd: projectDir, ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.*/**'], }); - const contents: string[] = []; + const sources: ClientSource[] = []; for (let i = 0; i < files.length; i += SCAN_CONCURRENCY) - contents.push( - ...(await Promise.all(files.slice(i, i + SCAN_CONCURRENCY).map((file) => readOrEmpty(join(projectDir, file))))), + sources.push( + ...(await Promise.all( + files + .slice(i, i + SCAN_CONCURRENCY) + .map(async (file) => ({ file, content: await readOrEmpty(join(projectDir, file)) })), + )), ); - return contents; + return sources; } /** From f1098b687eebec11eee5d9cceedb5713c3c65637 Mon Sep 17 00:00:00 2001 From: Jason Barry Date: Thu, 24 Sep 2026 17:08:25 -0700 Subject: [PATCH 09/34] refactor(install): key browser env access rules by bundler prefix Replace two prefix ternaries with one table typed against the prefixes getClientEnvPrefix returns, so a new bundler cannot fall through to the Create React App rules. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018EgeNr2FXXVcJ5o165U5Lx --- src/lib/validation/validator.ts | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/lib/validation/validator.ts b/src/lib/validation/validator.ts index d252a5db..def3512f 100644 --- a/src/lib/validation/validator.ts +++ b/src/lib/validation/validator.ts @@ -302,10 +302,12 @@ async function validateClientOnlyApp(framework: string, projectDir: string, issu const prefix = getClientEnvPrefix(projectDir); if (!prefix) return; const written = `${prefix}WORKOS_REDIRECT_URI`; - // Only browser code must read the prefixed var; vite.config.ts or a script may read the unprefixed one. - const browserSources = prefix === 'VITE_' ? sources : sources.filter(({ file }) => file.startsWith('src/')); - const pattern = prefix === 'VITE_' ? VITE_REDIRECT_ENV_REFERENCE : CRA_REDIRECT_ENV_REFERENCE; - const read = new Set(browserSources.flatMap(({ content }) => [...content.matchAll(pattern)].map((m) => m[1]))); + const { reference, isBrowserFile } = BROWSER_REDIRECT_ENV[prefix]; + const read = new Set( + sources + .filter(({ file }) => isBrowserFile(file)) + .flatMap(({ content }) => [...content.matchAll(reference)].map((m) => m[1])), + ); read.delete(written); for (const name of read) issues.push({ @@ -316,9 +318,21 @@ async function validateClientOnlyApp(framework: string, projectDir: string, issu }); } -/** How browser code reads its redirect URI: Vite through import.meta.env, Create React App through process.env in src/. */ -const VITE_REDIRECT_ENV_REFERENCE = /import\.meta\.env\.(\w*WORKOS_REDIRECT_URI)\b/g; -const CRA_REDIRECT_ENV_REFERENCE = /process\.env\.(\w*WORKOS_REDIRECT_URI)\b/g; +/** + * How each bundler's browser code reads its redirect URI. Only browser code + * must read the prefixed var; vite.config.ts or a script may read the + * unprefixed one. + */ +const BROWSER_REDIRECT_ENV = { + VITE_: { reference: /import\.meta\.env\.(\w*WORKOS_REDIRECT_URI)\b/g, isBrowserFile: () => true }, + REACT_APP_: { + reference: /process\.env\.(\w*WORKOS_REDIRECT_URI)\b/g, + isBrowserFile: (file: string) => file.startsWith('src/'), + }, +} satisfies Record< + NonNullable>, + { reference: RegExp; isBrowserFile: (file: string) => boolean } +>; /** Code that serves a route rather than linking to it: router config or a pathname check. */ const ROUTE_DECLARATIONS = [ @@ -368,7 +382,7 @@ async function readClientSource(projectDir: string): Promise { ...(await Promise.all( files .slice(i, i + SCAN_CONCURRENCY) - .map(async (file) => ({ file, content: await readOrEmpty(join(projectDir, file)) })), + .map((file) => readOrEmpty(join(projectDir, file)).then((content) => ({ file, content }))), )), ); return sources; From 266e624bf5574df211de0fff64f77011eb65ad3b Mon Sep 17 00:00:00 2001 From: Jason Barry Date: Thu, 24 Sep 2026 17:14:54 -0700 Subject: [PATCH 10/34] fix(install): flag process.env redirect URI reads in Vite browser code Vite browser code has no process.env, but the previous change checked only import.meta.env reads for Vite apps. Match the full access expression instead: count every import.meta.env read and process.env reads in src/, and require the one expression the bundler exposes. vite.config.ts and scripts may still read the unprefixed var. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018EgeNr2FXXVcJ5o165U5Lx --- src/lib/validation/validator.spec.ts | 26 ++++++++++++++-- src/lib/validation/validator.ts | 44 +++++++++++++--------------- 2 files changed, 45 insertions(+), 25 deletions(-) diff --git a/src/lib/validation/validator.spec.ts b/src/lib/validation/validator.spec.ts index 8ab7ed27..4038798b 100644 --- a/src/lib/validation/validator.spec.ts +++ b/src/lib/validation/validator.spec.ts @@ -823,7 +823,7 @@ describe('validateInstallation', () => { expect(result.issues).toContainEqual( expect.objectContaining({ severity: 'error', - message: `The client reads ${name}, but the installer writes VITE_WORKOS_REDIRECT_URI`, + message: `The client reads import.meta.env.${name}, but the installer exposes import.meta.env.VITE_WORKOS_REDIRECT_URI`, }), ); }, @@ -843,6 +843,27 @@ describe('validateInstallation', () => { expect(result.issues.some((i) => i.message.startsWith('The client reads'))).toBe(false); }); + it.each(['WORKOS_REDIRECT_URI', 'VITE_WORKOS_REDIRECT_URI'])( + 'flags Vite browser code that reads process.env.%s', + async (name) => { + writeFileSync(join(testDir, 'package.json'), JSON.stringify({ devDependencies: { vite: '^6.0.0' } })); + mkdirSync(join(testDir, 'src'), { recursive: true }); + writeFileSync( + join(testDir, 'src', 'main.tsx'), + ``, + ); + + const result = await validateInstallation('react', testDir, { runBuild: false }); + + expect(result.issues).toContainEqual( + expect.objectContaining({ + severity: 'error', + message: `The client reads process.env.${name}, but the installer exposes import.meta.env.VITE_WORKOS_REDIRECT_URI`, + }), + ); + }, + ); + it('flags a Create React App client that reads the unprefixed redirect URI', async () => { writeFileSync(join(testDir, 'package.json'), JSON.stringify({ dependencies: { 'react-scripts': '5.0.1' } })); mkdirSync(join(testDir, 'src'), { recursive: true }); @@ -855,7 +876,8 @@ describe('validateInstallation', () => { expect(result.issues).toContainEqual( expect.objectContaining({ - message: 'The client reads WORKOS_REDIRECT_URI, but the installer writes REACT_APP_WORKOS_REDIRECT_URI', + message: + 'The client reads process.env.WORKOS_REDIRECT_URI, but the installer exposes process.env.REACT_APP_WORKOS_REDIRECT_URI', }), ); }); diff --git a/src/lib/validation/validator.ts b/src/lib/validation/validator.ts index def3512f..b3e517dd 100644 --- a/src/lib/validation/validator.ts +++ b/src/lib/validation/validator.ts @@ -301,38 +301,36 @@ async function validateClientOnlyApp(framework: string, projectDir: string, issu }); const prefix = getClientEnvPrefix(projectDir); if (!prefix) return; - const written = `${prefix}WORKOS_REDIRECT_URI`; - const { reference, isBrowserFile } = BROWSER_REDIRECT_ENV[prefix]; - const read = new Set( - sources - .filter(({ file }) => isBrowserFile(file)) - .flatMap(({ content }) => [...content.matchAll(reference)].map((m) => m[1])), + const expected = BROWSER_REDIRECT_ENV[prefix]; + const reads = new Set( + sources.flatMap(({ file, content }) => + [...content.matchAll(REDIRECT_ENV_REFERENCE)] + .filter(([, access]) => access === 'import.meta.env' || file.startsWith('src/')) + .map(([read]) => read), + ), ); - read.delete(written); - for (const name of read) + reads.delete(expected); + for (const read of reads) issues.push({ type: 'env', severity: 'error', - message: `The client reads ${name}, but the installer writes ${written}`, - hint: `Read ${written} as the redirect URI.`, + message: `The client reads ${read}, but the installer exposes ${expected}`, + hint: `Read ${expected} as the redirect URI.`, }); } +/** The one expression each bundler's browser code must use to read the installed redirect URI. */ +const BROWSER_REDIRECT_ENV = { + VITE_: 'import.meta.env.VITE_WORKOS_REDIRECT_URI', + REACT_APP_: 'process.env.REACT_APP_WORKOS_REDIRECT_URI', +} satisfies Record>, string>; + /** - * How each bundler's browser code reads its redirect URI. Only browser code - * must read the prefixed var; vite.config.ts or a script may read the - * unprefixed one. + * A redirect URI env read. `import.meta.env` only exists in browser code; + * `process.env` counts only in src/, so vite.config.ts or a script may still + * read the unprefixed var. */ -const BROWSER_REDIRECT_ENV = { - VITE_: { reference: /import\.meta\.env\.(\w*WORKOS_REDIRECT_URI)\b/g, isBrowserFile: () => true }, - REACT_APP_: { - reference: /process\.env\.(\w*WORKOS_REDIRECT_URI)\b/g, - isBrowserFile: (file: string) => file.startsWith('src/'), - }, -} satisfies Record< - NonNullable>, - { reference: RegExp; isBrowserFile: (file: string) => boolean } ->; +const REDIRECT_ENV_REFERENCE = /(import\.meta\.env|process\.env)\.\w*WORKOS_REDIRECT_URI\b/g; /** Code that serves a route rather than linking to it: router config or a pathname check. */ const ROUTE_DECLARATIONS = [ From 064f86f4d4064d981d3a63b9b5998870452f4db6 Mon Sep 17 00:00:00 2001 From: Jason Barry Date: Thu, 24 Sep 2026 17:16:28 -0700 Subject: [PATCH 11/34] refactor(install): define client browser code once for all checks Read only browser code for the client-only checks: leave out config files, scripts, server code and tests, which run in Node. The sign-in route, redirectUri and env checks now share one file set, and the env check needs no per-accessor path rule. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018EgeNr2FXXVcJ5o165U5Lx --- src/lib/validation/validator.spec.ts | 69 +++++++++++----------------- src/lib/validation/validator.ts | 57 +++++++++++------------ 2 files changed, 55 insertions(+), 71 deletions(-) diff --git a/src/lib/validation/validator.spec.ts b/src/lib/validation/validator.spec.ts index 4038798b..b67957e0 100644 --- a/src/lib/validation/validator.spec.ts +++ b/src/lib/validation/validator.spec.ts @@ -799,6 +799,15 @@ describe('validateInstallation', () => { expect(signInIssue(result.issues)?.severity).toBe('error'); }); + it('ignores a /login route declared only in a test file', async () => { + mkdirSync(join(testDir, 'src'), { recursive: true }); + writeFileSync(join(testDir, 'src', 'App.test.tsx'), '} />'); + + const result = await validateInstallation('react', testDir, { runBuild: false }); + + expect(signInIssue(result.issues)).toBeDefined(); + }); + it('does not accept a /login page that never starts sign-in', async () => { mkdirSync(join(testDir, 'login'), { recursive: true }); writeFileSync(join(testDir, 'login', 'index.html'), '

Log in

'); @@ -808,27 +817,6 @@ describe('validateInstallation', () => { expect(signInIssue(result.issues)).toBeDefined(); }); - it.each(['WORKOS_REDIRECT_URI', 'REACT_APP_WORKOS_REDIRECT_URI'])( - 'fails when a Vite client reads %s instead of the var the installer writes', - async (name) => { - writeFileSync(join(testDir, 'package.json'), JSON.stringify({ devDependencies: { vite: '^6.0.0' } })); - mkdirSync(join(testDir, 'src'), { recursive: true }); - writeFileSync( - join(testDir, 'src', 'main.ts'), - `createClient(clientId, { redirectUri: import.meta.env.${name} });`, - ); - - const result = await validateInstallation('vanilla-js', testDir, { runBuild: false }); - - expect(result.issues).toContainEqual( - expect.objectContaining({ - severity: 'error', - message: `The client reads import.meta.env.${name}, but the installer exposes import.meta.env.VITE_WORKOS_REDIRECT_URI`, - }), - ); - }, - ); - it('lets vite.config.ts read the unprefixed redirect URI', async () => { writeFileSync(join(testDir, 'package.json'), JSON.stringify({ devDependencies: { vite: '^6.0.0' } })); writeFileSync(join(testDir, 'vite.config.ts'), 'const callback = process.env.WORKOS_REDIRECT_URI;'); @@ -843,26 +831,25 @@ describe('validateInstallation', () => { expect(result.issues.some((i) => i.message.startsWith('The client reads'))).toBe(false); }); - it.each(['WORKOS_REDIRECT_URI', 'VITE_WORKOS_REDIRECT_URI'])( - 'flags Vite browser code that reads process.env.%s', - async (name) => { - writeFileSync(join(testDir, 'package.json'), JSON.stringify({ devDependencies: { vite: '^6.0.0' } })); - mkdirSync(join(testDir, 'src'), { recursive: true }); - writeFileSync( - join(testDir, 'src', 'main.tsx'), - ``, - ); - - const result = await validateInstallation('react', testDir, { runBuild: false }); - - expect(result.issues).toContainEqual( - expect.objectContaining({ - severity: 'error', - message: `The client reads process.env.${name}, but the installer exposes import.meta.env.VITE_WORKOS_REDIRECT_URI`, - }), - ); - }, - ); + it.each([ + 'import.meta.env.WORKOS_REDIRECT_URI', + 'import.meta.env.REACT_APP_WORKOS_REDIRECT_URI', + 'process.env.WORKOS_REDIRECT_URI', + 'process.env.VITE_WORKOS_REDIRECT_URI', + ])('flags Vite browser code that reads %s', async (read) => { + writeFileSync(join(testDir, 'package.json'), JSON.stringify({ devDependencies: { vite: '^6.0.0' } })); + mkdirSync(join(testDir, 'src'), { recursive: true }); + writeFileSync(join(testDir, 'src', 'main.ts'), `createClient(clientId, { redirectUri: ${read} });`); + + const result = await validateInstallation('vanilla-js', testDir, { runBuild: false }); + + expect(result.issues).toContainEqual( + expect.objectContaining({ + severity: 'error', + message: `The client reads ${read}, but the installer exposes import.meta.env.VITE_WORKOS_REDIRECT_URI`, + }), + ); + }); it('flags a Create React App client that reads the unprefixed redirect URI', async () => { writeFileSync(join(testDir, 'package.json'), JSON.stringify({ dependencies: { 'react-scripts': '5.0.1' } })); diff --git a/src/lib/validation/validator.ts b/src/lib/validation/validator.ts index b3e517dd..17c60c88 100644 --- a/src/lib/validation/validator.ts +++ b/src/lib/validation/validator.ts @@ -292,7 +292,7 @@ async function validateClientOnlyApp(framework: string, projectDir: string, issu message: `No ${signInPath} route starts sign-in`, hint: `Register a public ${signInPath} client route that calls the SDK's signIn() on load. A link to ${signInPath} is not a route. The installer saves it as the Initiate login URI.`, }); - if (!sources.some(({ content }) => content.includes('redirectUri'))) + if (!sources.some((content) => content.includes('redirectUri'))) issues.push({ type: 'pattern', severity: 'error', @@ -303,11 +303,7 @@ async function validateClientOnlyApp(framework: string, projectDir: string, issu if (!prefix) return; const expected = BROWSER_REDIRECT_ENV[prefix]; const reads = new Set( - sources.flatMap(({ file, content }) => - [...content.matchAll(REDIRECT_ENV_REFERENCE)] - .filter(([, access]) => access === 'import.meta.env' || file.startsWith('src/')) - .map(([read]) => read), - ), + sources.flatMap((content) => [...content.matchAll(REDIRECT_ENV_REFERENCE)].map(([read]) => read)), ); reads.delete(expected); for (const read of reads) @@ -325,12 +321,8 @@ const BROWSER_REDIRECT_ENV = { REACT_APP_: 'process.env.REACT_APP_WORKOS_REDIRECT_URI', } satisfies Record>, string>; -/** - * A redirect URI env read. `import.meta.env` only exists in browser code; - * `process.env` counts only in src/, so vite.config.ts or a script may still - * read the unprefixed var. - */ -const REDIRECT_ENV_REFERENCE = /(import\.meta\.env|process\.env)\.\w*WORKOS_REDIRECT_URI\b/g; +/** A redirect URI env read, e.g. import.meta.env.VITE_WORKOS_REDIRECT_URI. */ +const REDIRECT_ENV_REFERENCE = /(?:import\.meta\.env|process\.env)\.\w*WORKOS_REDIRECT_URI\b/g; /** Code that serves a route rather than linking to it: router config or a pathname check. */ const ROUTE_DECLARATIONS = [ @@ -345,9 +337,9 @@ export async function hasClientSignInRoute(projectDir: string, signInPath: strin return servesSignInRoute(projectDir, await readClientSource(projectDir), signInPath); } -async function servesSignInRoute(projectDir: string, sources: ClientSource[], signInPath: string): Promise { +async function servesSignInRoute(projectDir: string, sources: string[], signInPath: string): Promise { const route = new RegExp(`(?:${ROUTE_DECLARATIONS.join('|')})['"\`]${signInPath.replace(/\/$/, '')}/?['"\`]`); - if (sources.some(({ content }) => route.test(content))) return true; + if (sources.some((content) => route.test(content))) return true; // A static page at the path, e.g. login/index.html, that starts sign-in. const segment = signInPath.replace(/^\/|\/$/g, ''); const pages = await fg( @@ -363,27 +355,32 @@ const readOrEmpty = (path: string) => readFile(path, 'utf-8').catch(() => ''); const SCAN_CONCURRENCY = 32; -interface ClientSource { - file: string; - content: string; -} - -/** The app's own source files, read a bounded batch at a time. */ -async function readClientSource(projectDir: string): Promise { +/** + * The app's browser code, read a bounded batch at a time. Config files, + * scripts, server code and tests run in Node, so they may read unprefixed + * env vars and do not serve routes. + */ +async function readClientSource(projectDir: string): Promise { const files = await fg(['**/*.{ts,tsx,js,jsx,mjs,html,htm}'], { cwd: projectDir, - ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.*/**'], + ignore: [ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/.*/**', + '**/*.config.*', + '**/scripts/**', + '**/server/**', + '**/__tests__/**', + '**/*.{spec,test}.*', + ], }); - const sources: ClientSource[] = []; + const contents: string[] = []; for (let i = 0; i < files.length; i += SCAN_CONCURRENCY) - sources.push( - ...(await Promise.all( - files - .slice(i, i + SCAN_CONCURRENCY) - .map((file) => readOrEmpty(join(projectDir, file)).then((content) => ({ file, content }))), - )), + contents.push( + ...(await Promise.all(files.slice(i, i + SCAN_CONCURRENCY).map((file) => readOrEmpty(join(projectDir, file))))), ); - return sources; + return contents; } /** From 480e695376574cb157874b724f5ff0d465fea26a Mon Sep 17 00:00:00 2001 From: Jason Barry Date: Thu, 24 Sep 2026 17:24:50 -0700 Subject: [PATCH 12/34] fix(install): scope client browser code by bundler Create React App compiles only src/, so a root server.js that reads the unprefixed redirect URI no longer fails validation. For Vite and other client apps, leave out only the Node files at the project root (bundler config, a server, scripts), so a browser config such as src/auth.config.ts is still checked. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018EgeNr2FXXVcJ5o165U5Lx --- src/lib/validation/validator.spec.ts | 28 ++++++++++++++++++++ src/lib/validation/validator.ts | 39 ++++++++++++++++------------ 2 files changed, 50 insertions(+), 17 deletions(-) diff --git a/src/lib/validation/validator.spec.ts b/src/lib/validation/validator.spec.ts index b67957e0..b2fe569b 100644 --- a/src/lib/validation/validator.spec.ts +++ b/src/lib/validation/validator.spec.ts @@ -799,6 +799,34 @@ describe('validateInstallation', () => { expect(signInIssue(result.issues)?.severity).toBe('error'); }); + it('reads AuthKit setup from a config file inside src/', async () => { + writeFileSync(join(testDir, 'package.json'), JSON.stringify({ devDependencies: { vite: '^6.0.0' } })); + mkdirSync(join(testDir, 'src'), { recursive: true }); + writeFileSync( + join(testDir, 'src', 'auth.config.ts'), + "export const routes = [{ path: '/login' }];\nexport const redirectUri = import.meta.env.VITE_WORKOS_REDIRECT_URI;", + ); + + const result = await validateInstallation('react', testDir, { runBuild: false }); + + expect(signInIssue(result.issues)).toBeUndefined(); + expect(redirectIssue(result.issues)).toBeUndefined(); + }); + + it('lets a Create React App server outside src/ read the unprefixed redirect URI', async () => { + writeFileSync(join(testDir, 'package.json'), JSON.stringify({ dependencies: { 'react-scripts': '5.0.1' } })); + writeFileSync(join(testDir, 'server.js'), 'const callback = process.env.WORKOS_REDIRECT_URI;'); + mkdirSync(join(testDir, 'src'), { recursive: true }); + writeFileSync( + join(testDir, 'src', 'index.tsx'), + '', + ); + + const result = await validateInstallation('react', testDir, { runBuild: false }); + + expect(result.issues.some((i) => i.message.startsWith('The client reads'))).toBe(false); + }); + it('ignores a /login route declared only in a test file', async () => { mkdirSync(join(testDir, 'src'), { recursive: true }); writeFileSync(join(testDir, 'src', 'App.test.tsx'), '} />'); diff --git a/src/lib/validation/validator.ts b/src/lib/validation/validator.ts index 17c60c88..b94efe5c 100644 --- a/src/lib/validation/validator.ts +++ b/src/lib/validation/validator.ts @@ -355,26 +355,31 @@ const readOrEmpty = (path: string) => readFile(path, 'utf-8').catch(() => ''); const SCAN_CONCURRENCY = 32; +const NOT_BROWSER_CODE = ['**/node_modules/**', '**/__tests__/**', '**/*.{spec,test}.*']; + /** - * The app's browser code, read a bounded batch at a time. Config files, - * scripts, server code and tests run in Node, so they may read unprefixed - * env vars and do not serve routes. + * The app's browser code, read a bounded batch at a time. Create React App + * compiles only src/. Elsewhere, leave out the Node files at the project root + * (bundler config, a server, scripts): they may read unprefixed env vars and + * do not serve routes. Config files inside src/ stay in. */ async function readClientSource(projectDir: string): Promise { - const files = await fg(['**/*.{ts,tsx,js,jsx,mjs,html,htm}'], { - cwd: projectDir, - ignore: [ - '**/node_modules/**', - '**/dist/**', - '**/build/**', - '**/.*/**', - '**/*.config.*', - '**/scripts/**', - '**/server/**', - '**/__tests__/**', - '**/*.{spec,test}.*', - ], - }); + const files = + getClientEnvPrefix(projectDir) === 'REACT_APP_' + ? await fg(['src/**/*.{ts,tsx,js,jsx,mjs}'], { cwd: projectDir, ignore: NOT_BROWSER_CODE }) + : await fg(['**/*.{ts,tsx,js,jsx,mjs,html,htm}'], { + cwd: projectDir, + ignore: [ + ...NOT_BROWSER_CODE, + '**/dist/**', + '**/build/**', + '**/.*/**', + '*.config.*', + 'server.*', + 'server/**', + 'scripts/**', + ], + }); const contents: string[] = []; for (let i = 0; i < files.length; i += SCAN_CONCURRENCY) contents.push( From bc176f4b35e4d1b1f0a8eda26520fc6f604f82bb Mon Sep 17 00:00:00 2001 From: Jason Barry Date: Thu, 24 Sep 2026 17:25:53 -0700 Subject: [PATCH 13/34] refactor(install): read client browser code with one glob Use one fast-glob call whose root depends on the bundler, look up the bundler prefix once per validation, and add an envReadIssue spec helper. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018EgeNr2FXXVcJ5o165U5Lx --- src/lib/validation/validator.spec.ts | 6 ++-- src/lib/validation/validator.ts | 41 +++++++++++++--------------- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/src/lib/validation/validator.spec.ts b/src/lib/validation/validator.spec.ts index b2fe569b..d59d357f 100644 --- a/src/lib/validation/validator.spec.ts +++ b/src/lib/validation/validator.spec.ts @@ -752,6 +752,8 @@ describe('validateInstallation', () => { describe('client-only sign-in route validation', () => { const signInIssue = (issues: { message: string }[]) => issues.find((i) => i.message.includes('/login route')); const redirectIssue = (issues: { message: string }[]) => issues.find((i) => i.message.includes('redirectUri')); + const envReadIssue = (issues: { message: string }[]) => + issues.find((i) => i.message.startsWith('The client reads')); it('fails a React SPA without a /login route', async () => { mkdirSync(join(testDir, 'src'), { recursive: true }); @@ -824,7 +826,7 @@ describe('validateInstallation', () => { const result = await validateInstallation('react', testDir, { runBuild: false }); - expect(result.issues.some((i) => i.message.startsWith('The client reads'))).toBe(false); + expect(envReadIssue(result.issues)).toBeUndefined(); }); it('ignores a /login route declared only in a test file', async () => { @@ -856,7 +858,7 @@ describe('validateInstallation', () => { const result = await validateInstallation('vanilla-js', testDir, { runBuild: false }); - expect(result.issues.some((i) => i.message.startsWith('The client reads'))).toBe(false); + expect(envReadIssue(result.issues)).toBeUndefined(); }); it.each([ diff --git a/src/lib/validation/validator.ts b/src/lib/validation/validator.ts index b94efe5c..3e918613 100644 --- a/src/lib/validation/validator.ts +++ b/src/lib/validation/validator.ts @@ -284,7 +284,8 @@ export async function validateFrameworkSpecific(framework: string, projectDir: s */ async function validateClientOnlyApp(framework: string, projectDir: string, issues: ValidationIssue[]) { const signInPath = getSignInPath(framework); - const sources = await readClientSource(projectDir); + const prefix = getClientEnvPrefix(projectDir); + const sources = await readClientSource(projectDir, prefix); if (signInPath && !(await servesSignInRoute(projectDir, sources, signInPath))) issues.push({ type: 'file', @@ -299,7 +300,6 @@ async function validateClientOnlyApp(framework: string, projectDir: string, issu message: 'The AuthKit client does not set redirectUri', hint: 'Pass the installed WORKOS_REDIRECT_URI env var as redirectUri to AuthKitProvider or createClient(). The SDK default, the page origin, is not registered.', }); - const prefix = getClientEnvPrefix(projectDir); if (!prefix) return; const expected = BROWSER_REDIRECT_ENV[prefix]; const reads = new Set( @@ -334,7 +334,7 @@ const ROUTE_DECLARATIONS = [ /** Whether a client-only app serves `signInPath`, from a route in its source or a static page. */ export async function hasClientSignInRoute(projectDir: string, signInPath: string): Promise { - return servesSignInRoute(projectDir, await readClientSource(projectDir), signInPath); + return servesSignInRoute(projectDir, await readClientSource(projectDir, getClientEnvPrefix(projectDir)), signInPath); } async function servesSignInRoute(projectDir: string, sources: string[], signInPath: string): Promise { @@ -355,31 +355,28 @@ const readOrEmpty = (path: string) => readFile(path, 'utf-8').catch(() => ''); const SCAN_CONCURRENCY = 32; -const NOT_BROWSER_CODE = ['**/node_modules/**', '**/__tests__/**', '**/*.{spec,test}.*']; - /** * The app's browser code, read a bounded batch at a time. Create React App * compiles only src/. Elsewhere, leave out the Node files at the project root * (bundler config, a server, scripts): they may read unprefixed env vars and * do not serve routes. Config files inside src/ stay in. */ -async function readClientSource(projectDir: string): Promise { - const files = - getClientEnvPrefix(projectDir) === 'REACT_APP_' - ? await fg(['src/**/*.{ts,tsx,js,jsx,mjs}'], { cwd: projectDir, ignore: NOT_BROWSER_CODE }) - : await fg(['**/*.{ts,tsx,js,jsx,mjs,html,htm}'], { - cwd: projectDir, - ignore: [ - ...NOT_BROWSER_CODE, - '**/dist/**', - '**/build/**', - '**/.*/**', - '*.config.*', - 'server.*', - 'server/**', - 'scripts/**', - ], - }); +async function readClientSource(projectDir: string, prefix: ReturnType): Promise { + const files = await fg([`${prefix === 'REACT_APP_' ? 'src/' : ''}**/*.{ts,tsx,js,jsx,mjs,html,htm}`], { + cwd: projectDir, + ignore: [ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/.*/**', + '**/__tests__/**', + '**/*.{spec,test}.*', + '*.config.*', + 'server.*', + 'server/**', + 'scripts/**', + ], + }); const contents: string[] = []; for (let i = 0; i < files.length; i += SCAN_CONCURRENCY) contents.push( From d5f937ebf6574bc16ba33abf8cb2e0f8d1e7b983 Mon Sep 17 00:00:00 2001 From: Jason Barry Date: Thu, 24 Sep 2026 17:40:45 -0700 Subject: [PATCH 14/34] fix(install): leave nested server code out of the client browser scan Server code in a nested folder such as src/server/ may read the unprefixed redirect URI. Exclude server files and folders at any depth; bundler config and scripts stay root-only so browser files in src/ are still checked. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018EgeNr2FXXVcJ5o165U5Lx --- src/lib/validation/validator.spec.ts | 17 +++++++++++++++++ src/lib/validation/validator.ts | 10 +++++----- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/lib/validation/validator.spec.ts b/src/lib/validation/validator.spec.ts index d59d357f..4c33487e 100644 --- a/src/lib/validation/validator.spec.ts +++ b/src/lib/validation/validator.spec.ts @@ -815,6 +815,23 @@ describe('validateInstallation', () => { expect(redirectIssue(result.issues)).toBeUndefined(); }); + it.each(['src/server/auth.ts', 'src/server.ts'])( + 'lets Vite server code in %s read the unprefixed redirect URI', + async (file) => { + writeFileSync(join(testDir, 'package.json'), JSON.stringify({ devDependencies: { vite: '^6.0.0' } })); + mkdirSync(join(testDir, 'src', 'server'), { recursive: true }); + writeFileSync(join(testDir, file), 'const callback = process.env.WORKOS_REDIRECT_URI;'); + writeFileSync( + join(testDir, 'src', 'main.ts'), + 'createClient(clientId, { redirectUri: import.meta.env.VITE_WORKOS_REDIRECT_URI });', + ); + + const result = await validateInstallation('vanilla-js', testDir, { runBuild: false }); + + expect(envReadIssue(result.issues)).toBeUndefined(); + }, + ); + it('lets a Create React App server outside src/ read the unprefixed redirect URI', async () => { writeFileSync(join(testDir, 'package.json'), JSON.stringify({ dependencies: { 'react-scripts': '5.0.1' } })); writeFileSync(join(testDir, 'server.js'), 'const callback = process.env.WORKOS_REDIRECT_URI;'); diff --git a/src/lib/validation/validator.ts b/src/lib/validation/validator.ts index 3e918613..5c930049 100644 --- a/src/lib/validation/validator.ts +++ b/src/lib/validation/validator.ts @@ -357,9 +357,9 @@ const SCAN_CONCURRENCY = 32; /** * The app's browser code, read a bounded batch at a time. Create React App - * compiles only src/. Elsewhere, leave out the Node files at the project root - * (bundler config, a server, scripts): they may read unprefixed env vars and - * do not serve routes. Config files inside src/ stay in. + * compiles only src/. Leave out Node code, which may read unprefixed env vars + * and does not serve routes: server code at any depth, and bundler config and + * scripts at the project root. Config files and scripts inside src/ stay in. */ async function readClientSource(projectDir: string, prefix: ReturnType): Promise { const files = await fg([`${prefix === 'REACT_APP_' ? 'src/' : ''}**/*.{ts,tsx,js,jsx,mjs,html,htm}`], { @@ -372,8 +372,8 @@ async function readClientSource(projectDir: string, prefix: ReturnType Date: Thu, 24 Sep 2026 17:42:15 -0700 Subject: [PATCH 15/34] refactor(install): classify redirect URI reads instead of guessing server paths Every server-path exclusion existed to hide one read: Node code reading process.env.WORKOS_REDIRECT_URI. Exempt that read for Vite, where the same read in browser code throws at page load (no process global), and drop the server/ and scripts/ exclusions. Create React App keeps flagging it, because CRA defines process.env in the browser and the read comes back undefined without an error. The silent cases (import.meta.env with the wrong name, process.env with a browser prefix) are still flagged in any file. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018EgeNr2FXXVcJ5o165U5Lx --- src/lib/validation/validator.spec.ts | 8 ++++---- src/lib/validation/validator.ts | 11 +++++------ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/lib/validation/validator.spec.ts b/src/lib/validation/validator.spec.ts index 4c33487e..5d7d3d80 100644 --- a/src/lib/validation/validator.spec.ts +++ b/src/lib/validation/validator.spec.ts @@ -815,11 +815,12 @@ describe('validateInstallation', () => { expect(redirectIssue(result.issues)).toBeUndefined(); }); - it.each(['src/server/auth.ts', 'src/server.ts'])( - 'lets Vite server code in %s read the unprefixed redirect URI', + it.each(['src/server/auth.ts', 'src/server.ts', 'server.js', 'api/auth.ts', 'functions/login.ts'])( + 'lets Vite server code in %s read the unprefixed redirect URI, wherever it lives', async (file) => { writeFileSync(join(testDir, 'package.json'), JSON.stringify({ devDependencies: { vite: '^6.0.0' } })); - mkdirSync(join(testDir, 'src', 'server'), { recursive: true }); + mkdirSync(join(testDir, file, '..'), { recursive: true }); + mkdirSync(join(testDir, 'src'), { recursive: true }); writeFileSync(join(testDir, file), 'const callback = process.env.WORKOS_REDIRECT_URI;'); writeFileSync( join(testDir, 'src', 'main.ts'), @@ -881,7 +882,6 @@ describe('validateInstallation', () => { it.each([ 'import.meta.env.WORKOS_REDIRECT_URI', 'import.meta.env.REACT_APP_WORKOS_REDIRECT_URI', - 'process.env.WORKOS_REDIRECT_URI', 'process.env.VITE_WORKOS_REDIRECT_URI', ])('flags Vite browser code that reads %s', async (read) => { writeFileSync(join(testDir, 'package.json'), JSON.stringify({ devDependencies: { vite: '^6.0.0' } })); diff --git a/src/lib/validation/validator.ts b/src/lib/validation/validator.ts index 5c930049..ba1f01f3 100644 --- a/src/lib/validation/validator.ts +++ b/src/lib/validation/validator.ts @@ -306,6 +306,9 @@ async function validateClientOnlyApp(framework: string, projectDir: string, issu sources.flatMap((content) => [...content.matchAll(REDIRECT_ENV_REFERENCE)].map(([read]) => read)), ); reads.delete(expected); + // Node code reads the unprefixed var. In Vite browser code the same read throws + // (no process global), so only Create React App needs it flagged. + if (prefix === 'VITE_') reads.delete('process.env.WORKOS_REDIRECT_URI'); for (const read of reads) issues.push({ type: 'env', @@ -357,9 +360,8 @@ const SCAN_CONCURRENCY = 32; /** * The app's browser code, read a bounded batch at a time. Create React App - * compiles only src/. Leave out Node code, which may read unprefixed env vars - * and does not serve routes: server code at any depth, and bundler config and - * scripts at the project root. Config files and scripts inside src/ stay in. + * compiles only src/; elsewhere, leave out the bundler config at the project + * root. Tests do not serve routes. */ async function readClientSource(projectDir: string, prefix: ReturnType): Promise { const files = await fg([`${prefix === 'REACT_APP_' ? 'src/' : ''}**/*.{ts,tsx,js,jsx,mjs,html,htm}`], { @@ -372,9 +374,6 @@ async function readClientSource(projectDir: string, prefix: ReturnType Date: Thu, 24 Sep 2026 17:50:26 -0700 Subject: [PATCH 16/34] fix(install): accept a /login pathname check through any variable The route check only recognised `pathname === '/login'`. The agent followed the prompt and wrote `const path = window.location.pathname; if (path === '/login')`, which failed validation in a real Vite app. Accept a comparison against any identifier, in either order. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018EgeNr2FXXVcJ5o165U5Lx --- src/lib/validation/validator.spec.ts | 12 ++++++++++++ src/lib/validation/validator.ts | 11 ++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/lib/validation/validator.spec.ts b/src/lib/validation/validator.spec.ts index 5d7d3d80..2f0db18c 100644 --- a/src/lib/validation/validator.spec.ts +++ b/src/lib/validation/validator.spec.ts @@ -792,6 +792,18 @@ describe('validateInstallation', () => { expect(signInIssue(result.issues)).toBeDefined(); }); + it.each([ + "const path = window.location.pathname;\nif (path === '/login') return ;", + "if ('/login' === location.pathname) signIn();", + ])('accepts a pathname check through any variable: %s', async (code) => { + mkdirSync(join(testDir, 'src'), { recursive: true }); + writeFileSync(join(testDir, 'src', 'main.tsx'), code); + + const result = await validateInstallation('react', testDir, { runBuild: false }); + + expect(signInIssue(result.issues)).toBeUndefined(); + }); + it('does not treat a link to /login as the route', async () => { mkdirSync(join(testDir, 'src'), { recursive: true }); writeFileSync(join(testDir, 'src', 'App.tsx'), 'Sign in'); diff --git a/src/lib/validation/validator.ts b/src/lib/validation/validator.ts index ba1f01f3..43d1192d 100644 --- a/src/lib/validation/validator.ts +++ b/src/lib/validation/validator.ts @@ -327,10 +327,13 @@ const BROWSER_REDIRECT_ENV = { /** A redirect URI env read, e.g. import.meta.env.VITE_WORKOS_REDIRECT_URI. */ const REDIRECT_ENV_REFERENCE = /(?:import\.meta\.env|process\.env)\.\w*WORKOS_REDIRECT_URI\b/g; -/** Code that serves a route rather than linking to it: router config or a pathname check. */ +/** + * Code that serves a route rather than linking to it: router config, or a + * pathname check such as `path === '/login'` or `case '/login':`. + */ const ROUTE_DECLARATIONS = [ String.raw`\bpath\s*[:=]\s*\{?\s*`, - String.raw`pathname\s*===?\s*`, + String.raw`\w\s*===?\s*`, String.raw`\bcase\s+`, String.raw`\bcreate(?:File)?Route\(\s*`, ]; @@ -341,7 +344,9 @@ export async function hasClientSignInRoute(projectDir: string, signInPath: strin } async function servesSignInRoute(projectDir: string, sources: string[], signInPath: string): Promise { - const route = new RegExp(`(?:${ROUTE_DECLARATIONS.join('|')})['"\`]${signInPath.replace(/\/$/, '')}/?['"\`]`); + const quoted = `['"\`]${signInPath.replace(/\/$/, '')}/?['"\`]`; + // Also the reversed comparison, `'/login' === path`. + const route = new RegExp(`(?:${ROUTE_DECLARATIONS.join('|')})${quoted}|${quoted}\\s*===?`); if (sources.some((content) => route.test(content))) return true; // A static page at the path, e.g. login/index.html, that starts sign-in. const segment = signInPath.replace(/^\/|\/$/g, ''); From ab52f4a8b2a2c1e63e591dc28c4d2e8ea53e2423 Mon Sep 17 00:00:00 2001 From: Jason Barry Date: Thu, 24 Sep 2026 17:51:30 -0700 Subject: [PATCH 17/34] refactor(install): name the accepted /login route forms in the prompt The agent prompt and the route check had no shared contract, so the agent could write a form the validator did not know. The prompt now names the forms the validator accepts, and the route regex uses String.raw like its declarations. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018EgeNr2FXXVcJ5o165U5Lx --- src/lib/agent-runner.spec.ts | 1 + src/lib/sign-in-route.ts | 2 +- src/lib/validation/validator.ts | 7 ++++--- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/lib/agent-runner.spec.ts b/src/lib/agent-runner.spec.ts index e4fffc19..30a7fc34 100644 --- a/src/lib/agent-runner.spec.ts +++ b/src/lib/agent-runner.spec.ts @@ -125,6 +125,7 @@ describe('installer prompt', () => { expect(prompt).toContain('## Sign-in route (Initiate login URI)'); expect(prompt).toContain('the app origin plus /login'); expect(prompt).toContain('add a /login client route'); + expect(prompt).toContain("compare window.location.pathname === '/login' at startup"); expect(prompt).toContain('VITE_WORKOS_REDIRECT_URI for Vite'); }); diff --git a/src/lib/sign-in-route.ts b/src/lib/sign-in-route.ts index 13c42489..a0cb1e1d 100644 --- a/src/lib/sign-in-route.ts +++ b/src/lib/sign-in-route.ts @@ -18,7 +18,7 @@ export function buildSignInSection({ if (!environment.requiresApiKey) { lines.push( '', - `This is a client-only app, so add a ${signInPath} client route that calls the SDK's signIn() as soon as the AuthKit client is ready, without a click (for example, in a useEffect). AuthKit keeps password-reset and invitation details through this redirect, so signIn() needs no extra arguments. If the app has a client router, register ${signInPath} in it. If it has none, check window.location.pathname at startup. Keep the existing sign-in button. Do not add a server route.`, + `This is a client-only app, so add a ${signInPath} client route that calls the SDK's signIn() as soon as the AuthKit client is ready, without a click (for example, in a useEffect). AuthKit keeps password-reset and invitation details through this redirect, so signIn() needs no extra arguments. If the app has a client router, register it there (for example path: '${signInPath}' or ). If it has none, compare window.location.pathname === '${signInPath}' at startup. The installer checks for one of these forms before it saves the Initiate login URI. Keep the existing sign-in button. Do not add a server route.`, '', "The installer registers WORKOS_REDIRECT_URI as the app's Redirect URI. The SDK defaults to the page origin instead, so pass that value explicitly. The installer also writes it to .env.local under the bundler's env prefix (VITE_WORKOS_REDIRECT_URI for Vite, REACT_APP_WORKOS_REDIRECT_URI for Create React App), with the client ID beside it. Read that variable and set it as redirectUri on AuthKitProvider or createClient().", ); diff --git a/src/lib/validation/validator.ts b/src/lib/validation/validator.ts index 43d1192d..7f5c7d86 100644 --- a/src/lib/validation/validator.ts +++ b/src/lib/validation/validator.ts @@ -329,7 +329,8 @@ const REDIRECT_ENV_REFERENCE = /(?:import\.meta\.env|process\.env)\.\w*WORKOS_RE /** * Code that serves a route rather than linking to it: router config, or a - * pathname check such as `path === '/login'` or `case '/login':`. + * pathname check such as `path === '/login'` or `case '/login':`. Keep in step + * with the forms the agent prompt names (buildSignInSection). */ const ROUTE_DECLARATIONS = [ String.raw`\bpath\s*[:=]\s*\{?\s*`, @@ -344,9 +345,9 @@ export async function hasClientSignInRoute(projectDir: string, signInPath: strin } async function servesSignInRoute(projectDir: string, sources: string[], signInPath: string): Promise { - const quoted = `['"\`]${signInPath.replace(/\/$/, '')}/?['"\`]`; + const quoted = String.raw`['"\`]${signInPath.replace(/\/$/, '')}/?['"\`]`; // Also the reversed comparison, `'/login' === path`. - const route = new RegExp(`(?:${ROUTE_DECLARATIONS.join('|')})${quoted}|${quoted}\\s*===?`); + const route = new RegExp(String.raw`(?:${ROUTE_DECLARATIONS.join('|')})${quoted}|${quoted}\s*===?`); if (sources.some((content) => route.test(content))) return true; // A static page at the path, e.g. login/index.html, that starts sign-in. const segment = signInPath.replace(/^\/|\/$/g, ''); From b4ffe866c2c9445db4cddd62f06a08f612b48ed9 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 19:55:01 -0500 Subject: [PATCH 18/34] fix(install): never write localhost URLs with a production API key The API-key setup step registers a localhost redirect URI and CORS origin and sets the homepage URL. It ran for any key, and a production key can reach the installer three ways: the --api-key flag or WORKOS_API_KEY, the project's env file, or an active profile of type production (the environment picker lists every profile with a key). With this PR running the step for every SDK, a Go, Rails, or Django install with a live key would add localhost URLs to production and replace its homepage. autoConfigureWorkOSEnvironment now does nothing unless the key starts with sk_test_, and reports the redirect URI and CORS origin as skipped with the reason, so the checklist and summary show them. The check sits in the function, not a caller, because the installer machine, agent-runner, and some integrations' own run() all reach it. It is the same sk_test_ test the Next.js API-key path already uses, so every write path now agrees. --- src/lib/run-with-core.setup.spec.ts | 21 +++++++++++++++ src/lib/workos-management.spec.ts | 40 ++++++++++++++++++++++++++--- src/lib/workos-management.ts | 23 ++++++++++++++++- 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/src/lib/run-with-core.setup.spec.ts b/src/lib/run-with-core.setup.spec.ts index cf0db87e..9e4ae1f8 100644 --- a/src/lib/run-with-core.setup.spec.ts +++ b/src/lib/run-with-core.setup.spec.ts @@ -11,6 +11,7 @@ import { } from './run-with-core.js'; import { configureAuthkitApplication } from './authkit-application-setup.js'; import { InstallDeclinedError } from './installer-errors.js'; +import { SANDBOX_ONLY_REASON } from './workos-management.js'; vi.mock('./authkit-application-setup.js', async (importOriginal) => ({ ...(await importOriginal()), @@ -163,6 +164,26 @@ describe('dashboard checklist reporting', () => { expect(events).toContain('config:step env-vars done'); }); + it.each(['sveltekit', 'go'])( + 'writes no localhost URLs to a production environment for %s, and says why', + async (integration) => { + const { emitter, events } = record(); + await configureInstallEnvironment({ + options, + integration, + credentials: { apiKey: 'sk_live_a', clientId: 'client_a' }, + emitter, + }); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(events).toEqual( + expect.arrayContaining([ + `config:step redirect-uri skipped (${SANDBOX_ONLY_REASON})`, + `config:step cors-origin skipped (${SANDBOX_ONLY_REASON})`, + ]), + ); + }, + ); + it('registers the URLs for a non-JavaScript SDK without writing .env.local', async () => { const { emitter, events } = record(); await configureInstallEnvironment({ diff --git a/src/lib/workos-management.spec.ts b/src/lib/workos-management.spec.ts index 1bf619e2..bf933d30 100644 --- a/src/lib/workos-management.spec.ts +++ b/src/lib/workos-management.spec.ts @@ -17,7 +17,7 @@ vi.mock('../utils/analytics.js', () => ({ const { analytics } = await import('../utils/analytics.js'); const ui = (await import('../utils/ui.js')).default; -const { autoConfigureWorkOSEnvironment } = await import('./workos-management.js'); +const { autoConfigureWorkOSEnvironment, SANDBOX_ONLY_REASON } = await import('./workos-management.js'); const API_KEY = 'sk_test_123'; const HOMEPAGE_ENDPOINT = 'https://api.workos.com/user_management/app_homepage_url'; @@ -290,11 +290,11 @@ describe('workos-management', () => { }); it('does not name a stored environment whose key did not do the writes', async () => { - // `--api-key sk_live_prod...` bypasses the store: the writes landed in the + // `--api-key sk_test_other...` bypasses the store: the writes landed in the // supplied key's environment, not the stored active one. getActiveEnvironment.mockReturnValue(unclaimedEnv); - await autoConfigureWorkOSEnvironment('sk_live_prod_999', INTEGRATION, PORT); + await autoConfigureWorkOSEnvironment('sk_test_other_999', INTEGRATION, PORT); const value = rowFor('Environment').value; expect(value).toBe('the API key supplied to this run'); @@ -305,7 +305,7 @@ describe('workos-management', () => { it('does not name a claimed stored environment whose key did not do the writes', async () => { getActiveEnvironment.mockReturnValue(claimedEnv); - await autoConfigureWorkOSEnvironment('sk_live_prod_999', INTEGRATION, PORT); + await autoConfigureWorkOSEnvironment('sk_test_other_999', INTEGRATION, PORT); const value = rowFor('Environment').value; expect(value).toBe('the API key supplied to this run'); @@ -324,6 +324,38 @@ describe('workos-management', () => { }); }); + describe('production keys', () => { + it.each([ + ['a live key', 'sk_live_prod_999'], + ['an unrecognized key', 'sk_prod_999'], + ])('writes nothing with %s and reports each item as skipped', async (_, apiKey) => { + const { calls } = stubFetch(() => jsonResponse(200, {})); + const steps: string[] = []; + + const result = await autoConfigureWorkOSEnvironment(apiKey, INTEGRATION, PORT, { + onStep: (step, status, detail) => steps.push(`${step}:${status}:${detail}`), + }); + + expect(result).toBeNull(); + expect(calls).toEqual([]); + expect(steps).toEqual([ + `redirect-uri:skipped:${SANDBOX_ONLY_REASON}`, + `cors-origin:skipped:${SANDBOX_ONLY_REASON}`, + ]); + expect(ui.log.warn).toHaveBeenCalledWith(SANDBOX_ONLY_REASON); + expect(ui.rows).not.toHaveBeenCalled(); + }); + + it('writes nothing with a live key even when it is the active profile', async () => { + getActiveEnvironment.mockReturnValue({ name: 'production', type: 'production', apiKey: 'sk_live_active' }); + const { calls } = stubFetch(() => jsonResponse(200, {})); + + await autoConfigureWorkOSEnvironment('sk_live_active', INTEGRATION, PORT); + + expect(calls).toEqual([]); + }); + }); + describe('checklist reporting', () => { it('reports each item as it resolves', async () => { stubFetch(() => jsonResponse(200, { url: 'http://elsewhere' })); diff --git a/src/lib/workos-management.ts b/src/lib/workos-management.ts index 7346c209..539a5819 100644 --- a/src/lib/workos-management.ts +++ b/src/lib/workos-management.ts @@ -184,9 +184,15 @@ export interface AutoConfigOptions { onStep?: (step: SetupItemId, status: SetupItemStatus, detail?: string) => void; } +/** Why local-development URLs are never written with a production key. */ +export const SANDBOX_ONLY_REASON = + 'Automatic setup only runs on sandbox environments (sk_test_ keys). Set production URLs in the WorkOS dashboard.'; + /** * Auto-configure WorkOS dashboard settings for local development. * Sets redirect URI, CORS origin, and homepage URL via the WorkOS API. + * Sandbox keys only: these are localhost URLs, and a production key can reach + * the installer from a flag, the project's env file, or the active profile. * * @param apiKey - WorkOS API key (sk_xxx) * @param integration - Framework integration type @@ -205,10 +211,25 @@ export async function autoConfigureWorkOSEnvironment( const callbackPath = getCallbackPath(integration); const callbackUrl = options.redirectUri || `${baseUrl}${callbackPath}`; const homepageUrlValue = options.homepageUrl || baseUrl; + const onStep = options.onStep ?? (() => {}); + + // The key's prefix is the one local fact the API enforces; a profile's + // `type` is only a local label. Checked here, not in a caller, because + // several callers (the installer machine, agent-runner, some integrations' + // own run()) reach this function. + if (!apiKey.startsWith('sk_test_')) { + for (const step of ['redirect-uri', 'cors-origin'] as const) onStep(step, 'skipped', SANDBOX_ONLY_REASON); + ui.log.warn(SANDBOX_ONLY_REASON); + analytics.capture(INSTALLER_INTERACTION_EVENT_NAME, { + action: 'workos environment auto-config skipped', + integration, + reason: 'non-sandbox key', + }); + return null; + } ui.log.step('Configuring WorkOS dashboard settings...'); - const onStep = options.onStep ?? (() => {}); // Report each item as it resolves, not when the whole batch does. const track = (step: SetupItemId, write: Promise): Promise => { onStep(step, 'started'); From 56a6f6753b7e9ddafba52754b48a5f99be89c18b Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 19:55:57 -0500 Subject: [PATCH 19/34] fix(install): leave a sandbox's homepage alone unless nothing can be overwritten The API-key setup step wrote the homepage URL on every sandbox. The homepage is one value per environment, and the REST API can set it but not read it (its GET answers 404), so the write replaced whatever a teammate had set on a shared sandbox. It now follows the rule #249 set for the Next.js API-key path: write it only when the user asked for it (--homepage-url) or the key belongs to the stored unclaimed environment, whose dashboard nobody has had. Otherwise the Homepage URL row says it wasn't changed. With a login, the later dashboard step reads the current homepage and fills an empty one. --- src/lib/workos-management.spec.ts | 61 +++++++++++++++++++++++++++++++ src/lib/workos-management.ts | 45 ++++++++++++++++++----- 2 files changed, 97 insertions(+), 9 deletions(-) diff --git a/src/lib/workos-management.spec.ts b/src/lib/workos-management.spec.ts index bf933d30..38a9143e 100644 --- a/src/lib/workos-management.spec.ts +++ b/src/lib/workos-management.spec.ts @@ -116,6 +116,12 @@ describe('workos-management', () => { }); describe('setHomepageUrl read-then-write', () => { + // The homepage is only written where nothing can be overwritten; an + // unclaimed environment is one (see 'homepage without a user choice'). + beforeEach(() => { + getActiveEnvironment.mockReturnValue(unclaimedEnv); + }); + it('skips the PUT when the current homepage URL already matches', async () => { const { calls } = stubFetch(() => jsonResponse(200, { url: BASE_URL })); @@ -324,6 +330,61 @@ describe('workos-management', () => { }); }); + describe('homepage without a user choice', () => { + it('leaves the homepage of a claimed environment alone and says so', async () => { + getActiveEnvironment.mockReturnValue(claimedEnv); + const { calls } = stubFetch(() => jsonResponse(404, {})); + + const result = await autoConfigureWorkOSEnvironment(API_KEY, INTEGRATION, PORT); + + expect(homepageCalls(calls, 'GET')).toHaveLength(0); + expect(homepageCalls(calls, 'PUT')).toHaveLength(0); + expect(result).not.toBeNull(); + expect(result?.homepageUrl).toBeUndefined(); + expect(rowFor('Homepage URL')).toMatchObject({ value: BASE_URL, statusKind: 'warn' }); + expect(analytics.capture).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ homepageUrl: 'skipped' }), + ); + }); + + it.each([ + ['no stored environment', () => null], + ['another environment’s unclaimed key', () => ({ ...unclaimedEnv, apiKey: 'sk_test_other' })], + [ + 'an unreadable keyring', + () => { + throw new Error('keyring locked'); + }, + ], + ])('leaves it alone with %s', async (_, active) => { + getActiveEnvironment.mockImplementation(active as () => EnvironmentConfig | null); + const { calls } = stubFetch(() => jsonResponse(404, {})); + + await autoConfigureWorkOSEnvironment(API_KEY, INTEGRATION, PORT); + + expect(homepageCalls(calls, 'PUT')).toHaveLength(0); + }); + + it('sets it on an unclaimed environment, whose dashboard nobody has used', async () => { + getActiveEnvironment.mockReturnValue(unclaimedEnv); + const { calls } = stubFetch((method) => (method === 'GET' ? jsonResponse(404, {}) : jsonResponse(200, {}))); + + await autoConfigureWorkOSEnvironment(API_KEY, INTEGRATION, PORT); + + expect(homepageCalls(calls, 'PUT')).toHaveLength(1); + }); + + it('sets the homepage the user asked for (--homepage-url) on any environment', async () => { + getActiveEnvironment.mockReturnValue(claimedEnv); + const { calls } = stubFetch((method) => (method === 'GET' ? jsonResponse(404, {}) : jsonResponse(200, {}))); + + await autoConfigureWorkOSEnvironment(API_KEY, INTEGRATION, PORT, { homepageUrl: 'https://app.example.com' }); + + expect(homepageCalls(calls, 'PUT')).toHaveLength(1); + }); + }); + describe('production keys', () => { it.each([ ['a live key', 'sk_live_prod_999'], diff --git a/src/lib/workos-management.ts b/src/lib/workos-management.ts index 539a5819..f9946c05 100644 --- a/src/lib/workos-management.ts +++ b/src/lib/workos-management.ts @@ -18,7 +18,8 @@ const SUPPLIED_KEY_PROVENANCE = 'the API key supplied to this run'; export interface AutoConfigResult { redirectUri: { success: boolean; alreadyExists: boolean }; corsOrigin: { success: boolean; alreadyExists: boolean }; - homepageUrl: { success: boolean; alreadyExists: boolean }; + /** Absent when the homepage was left alone (see `autoConfigureWorkOSEnvironment`). */ + homepageUrl?: { success: boolean; alreadyExists: boolean }; } interface FetchError { @@ -157,6 +158,17 @@ export async function setHomepageUrl( * store entirely, and naming an untouched environment is exactly the confusion * this row exists to prevent. */ +/** Whether `apiKey` is the key of the stored, still-unclaimed environment. */ +export function isUnclaimedEnvironmentKey(apiKey: string): boolean { + try { + const environment = getActiveEnvironment(); + return !!environment && isUnclaimedEnvironment(environment) && environment.apiKey === apiKey; + } catch { + // Keyring unavailable: unknown, so treat it as claimed. + return false; + } +} + function describeCredentialProvenance(apiKey: string): string { let activeEnv: EnvironmentConfig | null = null; try { @@ -228,6 +240,14 @@ export async function autoConfigureWorkOSEnvironment( return null; } + // The homepage is one value per environment, and the REST API can set it + // but not read it (the GET answers 404), so this step can't tell whether + // someone already chose one. Write it only when nothing can be overwritten: + // the user asked for it (--homepage-url), or the environment is unclaimed, + // so nobody has had its dashboard. With a login, the later dashboard step + // reads the current value and fills an empty one. + const writeHomepage = Boolean(options.homepageUrl) || isUnclaimedEnvironmentKey(apiKey); + ui.log.step('Configuring WorkOS dashboard settings...'); // Report each item as it resolves, not when the whole batch does. @@ -249,7 +269,7 @@ export async function autoConfigureWorkOSEnvironment( const [redirectUri, corsOrigin, homepageUrl] = await Promise.all([ track('redirect-uri', createRedirectUri(apiKey, callbackUrl)), track('cors-origin', createCorsOrigin(apiKey, baseUrl)), - setHomepageUrl(apiKey, homepageUrlValue), + writeHomepage ? setHomepageUrl(apiKey, homepageUrlValue) : undefined, ]); const results: AutoConfigResult = { redirectUri, corsOrigin, homepageUrl }; @@ -260,7 +280,7 @@ export async function autoConfigureWorkOSEnvironment( port, redirectUri: redirectUri.alreadyExists ? 'existed' : 'created', corsOrigin: corsOrigin.alreadyExists ? 'existed' : 'created', - homepageUrl: homepageUrl.alreadyExists ? 'existed' : 'updated', + homepageUrl: !homepageUrl ? 'skipped' : homepageUrl.alreadyExists ? 'existed' : 'updated', }); // Aligned key/value feedback: value in accent, a dim status for "already @@ -281,12 +301,19 @@ export async function autoConfigureWorkOSEnvironment( status: corsOrigin.alreadyExists ? 'already set' : 'created', statusKind: corsOrigin.alreadyExists ? 'muted' : 'ok', }, - { - key: 'Homepage URL', - value: homepageUrlValue, - status: homepageUrl.alreadyExists ? 'already set' : 'updated', - statusKind: homepageUrl.alreadyExists ? 'muted' : 'ok', - }, + homepageUrl + ? { + key: 'Homepage URL', + value: homepageUrlValue, + status: homepageUrl.alreadyExists ? 'already set' : 'updated', + statusKind: homepageUrl.alreadyExists ? 'muted' : 'ok', + } + : { + key: 'Homepage URL', + value: homepageUrlValue, + status: 'not changed; check the dashboard', + statusKind: 'warn', + }, ]); return results; From 40577ab67dc472ea503b1965e1ba841f863aaeff Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 20:10:23 -0500 Subject: [PATCH 20/34] fix(install): check the client code the browser loads, not every file The client-only checks scanned every source file in a Vite project, so they could not tell browser code from a server or script beside it. Each fix moved the gap: a /login route in server.js counted as the client route, and exempting server reads hid browser code that read process.env.WORKOS_REDIRECT_URI, which Vite leaves undefined. The route check also counted any `x === '/login'` comparison as a route. The scan now traces what the browser loads: every HTML page and its script srcs (Vite's index.html, a plain app's pages), or Create React App's src/index, then their relative, root and @/ imports. A server, a script or the bundler config is never reached from there, wherever it lives, so traced browser code that reads the unprefixed var is flagged again. A project with no entry keeps the old whole-project scan and its exemption. A comparison counts as the route only when it checks the pathname: `location.pathname === '/login'`, a variable read from it, or `case '/login':` in a switch on it. `path = '/login'` (an assignment) no longer passes as `path="/login"`. These are the forms the agent prompt names. --- src/lib/validation/validator.spec.ts | 90 ++++++++++++++- src/lib/validation/validator.ts | 158 +++++++++++++++++++++------ 2 files changed, 214 insertions(+), 34 deletions(-) diff --git a/src/lib/validation/validator.spec.ts b/src/lib/validation/validator.spec.ts index 2f0db18c..6f67d3c6 100644 --- a/src/lib/validation/validator.spec.ts +++ b/src/lib/validation/validator.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { validateInstallation } from './validator.js'; +import { hasClientSignInRoute, validateInstallation } from './validator.js'; describe('validateInstallation', () => { let testDir: string; @@ -940,6 +940,94 @@ describe('validateInstallation', () => { expect(redirectIssue(result.issues)?.severity).toBe('error'); }); + describe('browser code traced from the entry', () => { + const viteApp = (files: Record) => { + writeFileSync(join(testDir, 'package.json'), JSON.stringify({ devDependencies: { vite: '^6.0.0' } })); + writeFileSync(join(testDir, 'index.html'), ''); + for (const [file, content] of Object.entries(files)) { + mkdirSync(join(testDir, file, '..'), { recursive: true }); + writeFileSync(join(testDir, file), content); + } + }; + + it('flags browser code that reads process.env.WORKOS_REDIRECT_URI, which Vite leaves undefined', async () => { + viteApp({ + 'src/main.ts': "import { client } from './auth';", + 'src/auth.ts': 'export const client = createClient(id, { redirectUri: process.env.WORKOS_REDIRECT_URI });', + }); + + const result = await validateInstallation('vanilla-js', testDir, { runBuild: false }); + + expect(envReadIssue(result.issues)?.message).toBe( + 'The client reads process.env.WORKOS_REDIRECT_URI, but the installer exposes import.meta.env.VITE_WORKOS_REDIRECT_URI', + ); + }); + + it('still lets server code the browser never loads read the unprefixed redirect URI', async () => { + viteApp({ + 'src/main.ts': 'createClient(id, { redirectUri: import.meta.env.VITE_WORKOS_REDIRECT_URI });', + 'server.js': 'const callback = process.env.WORKOS_REDIRECT_URI;', + 'src/server/auth.ts': 'const callback = process.env.WORKOS_REDIRECT_URI;', + }); + + const result = await validateInstallation('vanilla-js', testDir, { runBuild: false }); + + expect(envReadIssue(result.issues)).toBeUndefined(); + }); + + it('does not count a /login route in server code the browser never loads', async () => { + viteApp({ + 'src/main.ts': 'createClient(id, { redirectUri: import.meta.env.VITE_WORKOS_REDIRECT_URI });', + 'server.js': "const routes = [{ path: '/login' }];", + 'scripts/routes.ts': "switch (location.pathname) { case '/login': break; }", + }); + + const result = await validateInstallation('vanilla-js', testDir, { runBuild: false }); + + expect(signInIssue(result.issues)).toBeDefined(); + expect(await hasClientSignInRoute(testDir, '/login')).toBe(false); + }); + + it('follows @/ aliases and TypeScript .js imports to the route', async () => { + viteApp({ + 'src/main.ts': "import { App } from '@/App';\nimport './auth.js';", + 'src/App.tsx': '} />', + 'src/auth.ts': 'createClient(id, { redirectUri: import.meta.env.VITE_WORKOS_REDIRECT_URI });', + }); + + const result = await validateInstallation('react', testDir, { runBuild: false }); + + expect(signInIssue(result.issues)).toBeUndefined(); + expect(redirectIssue(result.issues)).toBeUndefined(); + expect(await hasClientSignInRoute(testDir, '/login')).toBe(true); + }); + }); + + it.each([ + "if (redirectTarget === '/login') navigate(redirectTarget);", + "switch (action) { case '/login': break; }", + "const path = '/login';\nnavigate(path);", + ])('does not treat code that only mentions /login as the route: %s', async (code) => { + mkdirSync(join(testDir, 'src'), { recursive: true }); + writeFileSync(join(testDir, 'src', 'main.tsx'), code); + + const result = await validateInstallation('react', testDir, { runBuild: false }); + + expect(signInIssue(result.issues)).toBeDefined(); + }); + + it.each([ + "switch (window.location.pathname) { case '/login': signIn(); }", + "const { pathname } = window.location;\nif (pathname === '/login') signIn();", + ])('accepts a check of the page pathname: %s', async (code) => { + mkdirSync(join(testDir, 'src'), { recursive: true }); + writeFileSync(join(testDir, 'src', 'main.tsx'), code); + + const result = await validateInstallation('react', testDir, { runBuild: false }); + + expect(signInIssue(result.issues)).toBeUndefined(); + }); + it('accepts a client app that passes redirectUri', async () => { mkdirSync(join(testDir, 'src'), { recursive: true }); writeFileSync( diff --git a/src/lib/validation/validator.ts b/src/lib/validation/validator.ts index 7f5c7d86..f4f4d444 100644 --- a/src/lib/validation/validator.ts +++ b/src/lib/validation/validator.ts @@ -1,6 +1,6 @@ import { readFile } from 'fs/promises'; -import { existsSync } from 'fs'; -import { join } from 'path'; +import { existsSync, statSync } from 'fs'; +import { dirname, join, relative } from 'path'; import fg from 'fast-glob'; import type { ValidationResult, ValidationRules, ValidationIssue } from './types.js'; import { runBuildValidation } from './build-validator.js'; @@ -285,7 +285,7 @@ export async function validateFrameworkSpecific(framework: string, projectDir: s async function validateClientOnlyApp(framework: string, projectDir: string, issues: ValidationIssue[]) { const signInPath = getSignInPath(framework); const prefix = getClientEnvPrefix(projectDir); - const sources = await readClientSource(projectDir, prefix); + const { sources, traced } = await readClientSource(projectDir, prefix); if (signInPath && !(await servesSignInRoute(projectDir, sources, signInPath))) issues.push({ type: 'file', @@ -306,9 +306,11 @@ async function validateClientOnlyApp(framework: string, projectDir: string, issu sources.flatMap((content) => [...content.matchAll(REDIRECT_ENV_REFERENCE)].map(([read]) => read)), ); reads.delete(expected); - // Node code reads the unprefixed var. In Vite browser code the same read throws - // (no process global), so only Create React App needs it flagged. - if (prefix === 'VITE_') reads.delete('process.env.WORKOS_REDIRECT_URI'); + // Node code (a server, a script) reads the unprefixed var legitimately. Code + // traced from the browser's entry never does: Vite browser code has no + // process global. Only an untraced scan can mix the two, so only it lets + // the read through. + if (prefix === 'VITE_' && !traced) reads.delete('process.env.WORKOS_REDIRECT_URI'); for (const read of reads) issues.push({ type: 'env', @@ -327,28 +329,41 @@ const BROWSER_REDIRECT_ENV = { /** A redirect URI env read, e.g. import.meta.env.VITE_WORKOS_REDIRECT_URI. */ const REDIRECT_ENV_REFERENCE = /(?:import\.meta\.env|process\.env)\.\w*WORKOS_REDIRECT_URI\b/g; +const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + /** - * Code that serves a route rather than linking to it: router config, or a - * pathname check such as `path === '/login'` or `case '/login':`. Keep in step + * Whether `content` serves `signInPath` rather than linking to it: router + * config (`path: '/login'`, ``, `createFileRoute('/login')`), + * or a check of the page's pathname (`location.pathname === '/login'`, a + * variable read from it, or `case '/login':` in a `switch` on it). Keep in step * with the forms the agent prompt names (buildSignInSection). */ -const ROUTE_DECLARATIONS = [ - String.raw`\bpath\s*[:=]\s*\{?\s*`, - String.raw`\w\s*===?\s*`, - String.raw`\bcase\s+`, - String.raw`\bcreate(?:File)?Route\(\s*`, -]; +function declaresRoute(content: string, signInPath: string): boolean { + const quoted = String.raw`['"\`]${escapeRegExp(signInPath.replace(/\/$/, ''))}/?['"\`]`; + // `path=` with no space is a JSX attribute; `path = '/login'` is an assignment. + if (new RegExp(String.raw`\bpath(?:\s*:|=\{?)\s*${quoted}|\bcreate(?:File)?Route\(\s*${quoted}`).test(content)) + return true; + const pathnameVars = new Set( + [...content.matchAll(/\b(?:const|let|var)\s+(\w+)\s*=\s*[^;\n]*\bpathname\b/g)].map(([, name]) => name), + ); + const isPathname = (expression: string) => /(?:^|\.)pathname$/.test(expression) || pathnameVars.has(expression); + const compared = [ + ...[...content.matchAll(new RegExp(String.raw`([\w.$]+)\s*===?\s*${quoted}`, 'g'))].map(([, e]) => e), + ...[...content.matchAll(new RegExp(String.raw`${quoted}\s*===?\s*([\w.$]+)`, 'g'))].map(([, e]) => e), + ]; + if (compared.some(isPathname)) return true; + const switchesOnPathname = [...content.matchAll(/\bswitch\s*\(\s*([\w.$]+)\s*\)/g)].some(([, e]) => isPathname(e)); + return switchesOnPathname && new RegExp(String.raw`\bcase\s+${quoted}`).test(content); +} /** Whether a client-only app serves `signInPath`, from a route in its source or a static page. */ export async function hasClientSignInRoute(projectDir: string, signInPath: string): Promise { - return servesSignInRoute(projectDir, await readClientSource(projectDir, getClientEnvPrefix(projectDir)), signInPath); + const { sources } = await readClientSource(projectDir, getClientEnvPrefix(projectDir)); + return servesSignInRoute(projectDir, sources, signInPath); } async function servesSignInRoute(projectDir: string, sources: string[], signInPath: string): Promise { - const quoted = String.raw`['"\`]${signInPath.replace(/\/$/, '')}/?['"\`]`; - // Also the reversed comparison, `'/login' === path`. - const route = new RegExp(String.raw`(?:${ROUTE_DECLARATIONS.join('|')})${quoted}|${quoted}\s*===?`); - if (sources.some((content) => route.test(content))) return true; + if (sources.some((content) => declaresRoute(content, signInPath))) return true; // A static page at the path, e.g. login/index.html, that starts sign-in. const segment = signInPath.replace(/^\/|\/$/g, ''); const pages = await fg( @@ -363,31 +378,108 @@ async function servesSignInRoute(projectDir: string, sources: string[], signInPa const readOrEmpty = (path: string) => readFile(path, 'utf-8').catch(() => ''); const SCAN_CONCURRENCY = 32; +const MAX_BROWSER_FILES = 5_000; +const SOURCE_IGNORE = [ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/.*/**', + '**/__tests__/**', + '**/*.{spec,test}.*', +]; +const CODE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs']; +const SCRIPT_SRC = /]*?\bsrc\s*=\s*["']([^"']+)["']/gi; +const IMPORT_SPECIFIER = + /\b(?:import|export)\s+(?:[\w*{}\s,$]+\s+from\s+)?['"]([^'"]+)['"]|\b(?:import|require)\s*\(\s*['"]([^'"]+)['"]\s*\)/g; + +/** + * The project file an import or script src names, or undefined for a + * package, a URL, or nothing on disk. `/x` is the project root (Vite, plain + * pages); `@/` and `~/` are the usual aliases for src/. + */ +function resolveLocal(projectDir: string, fromFile: string, specifier: string): string | undefined { + let base: string; + if (specifier.startsWith('./') || specifier.startsWith('../')) + base = join(dirname(join(projectDir, fromFile)), specifier); + else if (specifier.startsWith('/')) base = join(projectDir, specifier); + else if (specifier.startsWith('@/') || specifier.startsWith('~/')) base = join(projectDir, 'src', specifier.slice(2)); + else return undefined; + base = base.replace(/[?#].*$/, ''); + const candidates = [ + base, + ...CODE_EXTENSIONS.map((ext) => base + ext), + ...CODE_EXTENSIONS.map((ext) => join(base, `index${ext}`)), + // TypeScript ESM names the emitted file: './auth.js' is auth.ts on disk. + ...(/\.jsx?$/.test(base) ? [base.replace(/\.jsx?$/, '.ts'), base.replace(/\.jsx?$/, '.tsx')] : []), + ]; + const found = candidates.find( + (path) => /\.(?:m?[jt]sx?|html?)$/.test(path) && existsSync(path) && statSync(path).isFile(), + ); + const file = found && relative(projectDir, found); + return file && !file.startsWith('..') && !file.split(/[\\/]/).includes('node_modules') ? file : undefined; +} /** - * The app's browser code, read a bounded batch at a time. Create React App - * compiles only src/; elsewhere, leave out the bundler config at the project - * root. Tests do not serve routes. + * The code the browser loads: every HTML page with its scripts (Vite's + * index.html, a plain app's pages) or Create React App's src/index, and + * everything those import. A server, a script, or the bundler config is never + * reached from there, wherever it sits. Undefined when there is no entry. */ -async function readClientSource(projectDir: string, prefix: ReturnType): Promise { +async function readBrowserCode(projectDir: string, prefix: ReturnType) { + const entries = await fg([prefix === 'REACT_APP_' ? 'src/index.{js,jsx,ts,tsx}' : '**/*.{html,htm}'], { + cwd: projectDir, + ignore: SOURCE_IGNORE, + }); + if (entries.length === 0) return undefined; + const seen = new Set(entries); + const queue = [...entries]; + const sources: string[] = []; + while (queue.length > 0) { + const batch = queue.splice(0, SCAN_CONCURRENCY); + const contents = await Promise.all(batch.map((file) => readOrEmpty(join(projectDir, file)))); + batch.forEach((file, i) => { + const content = contents[i]; + sources.push(content); + const specifiers = /\.html?$/.test(file) + ? [...content.matchAll(SCRIPT_SRC)] + .map(([, src]) => src) + .filter((src) => !/^(?:[a-z]+:)?\/\//i.test(src)) + .map((src) => (/^[./]/.test(src) ? src : `./${src}`)) + : [...content.matchAll(IMPORT_SPECIFIER)].map(([, from, call]) => from ?? call); + for (const specifier of specifiers) { + const next = resolveLocal(projectDir, file, specifier); + if (next && !seen.has(next) && seen.size < MAX_BROWSER_FILES) { + seen.add(next); + queue.push(next); + } + } + }); + } + return sources; +} + +/** + * The app's browser code: traced from its entry when it has one (see + * readBrowserCode), else every source file, read a bounded batch at a time. + * Create React App compiles only src/; elsewhere, leave out the bundler + * config at the project root. Tests do not serve routes. + */ +async function readClientSource( + projectDir: string, + prefix: ReturnType, +): Promise<{ sources: string[]; traced: boolean }> { + const traced = await readBrowserCode(projectDir, prefix); + if (traced) return { sources: traced, traced: true }; const files = await fg([`${prefix === 'REACT_APP_' ? 'src/' : ''}**/*.{ts,tsx,js,jsx,mjs,html,htm}`], { cwd: projectDir, - ignore: [ - '**/node_modules/**', - '**/dist/**', - '**/build/**', - '**/.*/**', - '**/__tests__/**', - '**/*.{spec,test}.*', - '*.config.*', - ], + ignore: [...SOURCE_IGNORE, '*.config.*'], }); const contents: string[] = []; for (let i = 0; i < files.length; i += SCAN_CONCURRENCY) contents.push( ...(await Promise.all(files.slice(i, i + SCAN_CONCURRENCY).map((file) => readOrEmpty(join(projectDir, file))))), ); - return contents; + return { sources: contents, traced: false }; } /** From 9acebb22ed5d55928f83adf9eea317ca951cce45 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 20:54:31 -0500 Subject: [PATCH 21/34] revert: restore client validation before browser-source tracing Revert df2d1f4ece71254be62008fcc88026ce6b044629. The scanner introduced false negatives for inline module imports and custom aliases without reliably proving that a route starts sign-in. Restore the previous validator and leave those review findings open. Keep the production-key guard and homepage protection unchanged. --- src/lib/validation/validator.spec.ts | 90 +-------------- src/lib/validation/validator.ts | 158 ++++++--------------------- 2 files changed, 34 insertions(+), 214 deletions(-) diff --git a/src/lib/validation/validator.spec.ts b/src/lib/validation/validator.spec.ts index 6f67d3c6..2f0db18c 100644 --- a/src/lib/validation/validator.spec.ts +++ b/src/lib/validation/validator.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { hasClientSignInRoute, validateInstallation } from './validator.js'; +import { validateInstallation } from './validator.js'; describe('validateInstallation', () => { let testDir: string; @@ -940,94 +940,6 @@ describe('validateInstallation', () => { expect(redirectIssue(result.issues)?.severity).toBe('error'); }); - describe('browser code traced from the entry', () => { - const viteApp = (files: Record) => { - writeFileSync(join(testDir, 'package.json'), JSON.stringify({ devDependencies: { vite: '^6.0.0' } })); - writeFileSync(join(testDir, 'index.html'), ''); - for (const [file, content] of Object.entries(files)) { - mkdirSync(join(testDir, file, '..'), { recursive: true }); - writeFileSync(join(testDir, file), content); - } - }; - - it('flags browser code that reads process.env.WORKOS_REDIRECT_URI, which Vite leaves undefined', async () => { - viteApp({ - 'src/main.ts': "import { client } from './auth';", - 'src/auth.ts': 'export const client = createClient(id, { redirectUri: process.env.WORKOS_REDIRECT_URI });', - }); - - const result = await validateInstallation('vanilla-js', testDir, { runBuild: false }); - - expect(envReadIssue(result.issues)?.message).toBe( - 'The client reads process.env.WORKOS_REDIRECT_URI, but the installer exposes import.meta.env.VITE_WORKOS_REDIRECT_URI', - ); - }); - - it('still lets server code the browser never loads read the unprefixed redirect URI', async () => { - viteApp({ - 'src/main.ts': 'createClient(id, { redirectUri: import.meta.env.VITE_WORKOS_REDIRECT_URI });', - 'server.js': 'const callback = process.env.WORKOS_REDIRECT_URI;', - 'src/server/auth.ts': 'const callback = process.env.WORKOS_REDIRECT_URI;', - }); - - const result = await validateInstallation('vanilla-js', testDir, { runBuild: false }); - - expect(envReadIssue(result.issues)).toBeUndefined(); - }); - - it('does not count a /login route in server code the browser never loads', async () => { - viteApp({ - 'src/main.ts': 'createClient(id, { redirectUri: import.meta.env.VITE_WORKOS_REDIRECT_URI });', - 'server.js': "const routes = [{ path: '/login' }];", - 'scripts/routes.ts': "switch (location.pathname) { case '/login': break; }", - }); - - const result = await validateInstallation('vanilla-js', testDir, { runBuild: false }); - - expect(signInIssue(result.issues)).toBeDefined(); - expect(await hasClientSignInRoute(testDir, '/login')).toBe(false); - }); - - it('follows @/ aliases and TypeScript .js imports to the route', async () => { - viteApp({ - 'src/main.ts': "import { App } from '@/App';\nimport './auth.js';", - 'src/App.tsx': '} />', - 'src/auth.ts': 'createClient(id, { redirectUri: import.meta.env.VITE_WORKOS_REDIRECT_URI });', - }); - - const result = await validateInstallation('react', testDir, { runBuild: false }); - - expect(signInIssue(result.issues)).toBeUndefined(); - expect(redirectIssue(result.issues)).toBeUndefined(); - expect(await hasClientSignInRoute(testDir, '/login')).toBe(true); - }); - }); - - it.each([ - "if (redirectTarget === '/login') navigate(redirectTarget);", - "switch (action) { case '/login': break; }", - "const path = '/login';\nnavigate(path);", - ])('does not treat code that only mentions /login as the route: %s', async (code) => { - mkdirSync(join(testDir, 'src'), { recursive: true }); - writeFileSync(join(testDir, 'src', 'main.tsx'), code); - - const result = await validateInstallation('react', testDir, { runBuild: false }); - - expect(signInIssue(result.issues)).toBeDefined(); - }); - - it.each([ - "switch (window.location.pathname) { case '/login': signIn(); }", - "const { pathname } = window.location;\nif (pathname === '/login') signIn();", - ])('accepts a check of the page pathname: %s', async (code) => { - mkdirSync(join(testDir, 'src'), { recursive: true }); - writeFileSync(join(testDir, 'src', 'main.tsx'), code); - - const result = await validateInstallation('react', testDir, { runBuild: false }); - - expect(signInIssue(result.issues)).toBeUndefined(); - }); - it('accepts a client app that passes redirectUri', async () => { mkdirSync(join(testDir, 'src'), { recursive: true }); writeFileSync( diff --git a/src/lib/validation/validator.ts b/src/lib/validation/validator.ts index f4f4d444..7f5c7d86 100644 --- a/src/lib/validation/validator.ts +++ b/src/lib/validation/validator.ts @@ -1,6 +1,6 @@ import { readFile } from 'fs/promises'; -import { existsSync, statSync } from 'fs'; -import { dirname, join, relative } from 'path'; +import { existsSync } from 'fs'; +import { join } from 'path'; import fg from 'fast-glob'; import type { ValidationResult, ValidationRules, ValidationIssue } from './types.js'; import { runBuildValidation } from './build-validator.js'; @@ -285,7 +285,7 @@ export async function validateFrameworkSpecific(framework: string, projectDir: s async function validateClientOnlyApp(framework: string, projectDir: string, issues: ValidationIssue[]) { const signInPath = getSignInPath(framework); const prefix = getClientEnvPrefix(projectDir); - const { sources, traced } = await readClientSource(projectDir, prefix); + const sources = await readClientSource(projectDir, prefix); if (signInPath && !(await servesSignInRoute(projectDir, sources, signInPath))) issues.push({ type: 'file', @@ -306,11 +306,9 @@ async function validateClientOnlyApp(framework: string, projectDir: string, issu sources.flatMap((content) => [...content.matchAll(REDIRECT_ENV_REFERENCE)].map(([read]) => read)), ); reads.delete(expected); - // Node code (a server, a script) reads the unprefixed var legitimately. Code - // traced from the browser's entry never does: Vite browser code has no - // process global. Only an untraced scan can mix the two, so only it lets - // the read through. - if (prefix === 'VITE_' && !traced) reads.delete('process.env.WORKOS_REDIRECT_URI'); + // Node code reads the unprefixed var. In Vite browser code the same read throws + // (no process global), so only Create React App needs it flagged. + if (prefix === 'VITE_') reads.delete('process.env.WORKOS_REDIRECT_URI'); for (const read of reads) issues.push({ type: 'env', @@ -329,41 +327,28 @@ const BROWSER_REDIRECT_ENV = { /** A redirect URI env read, e.g. import.meta.env.VITE_WORKOS_REDIRECT_URI. */ const REDIRECT_ENV_REFERENCE = /(?:import\.meta\.env|process\.env)\.\w*WORKOS_REDIRECT_URI\b/g; -const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - /** - * Whether `content` serves `signInPath` rather than linking to it: router - * config (`path: '/login'`, ``, `createFileRoute('/login')`), - * or a check of the page's pathname (`location.pathname === '/login'`, a - * variable read from it, or `case '/login':` in a `switch` on it). Keep in step + * Code that serves a route rather than linking to it: router config, or a + * pathname check such as `path === '/login'` or `case '/login':`. Keep in step * with the forms the agent prompt names (buildSignInSection). */ -function declaresRoute(content: string, signInPath: string): boolean { - const quoted = String.raw`['"\`]${escapeRegExp(signInPath.replace(/\/$/, ''))}/?['"\`]`; - // `path=` with no space is a JSX attribute; `path = '/login'` is an assignment. - if (new RegExp(String.raw`\bpath(?:\s*:|=\{?)\s*${quoted}|\bcreate(?:File)?Route\(\s*${quoted}`).test(content)) - return true; - const pathnameVars = new Set( - [...content.matchAll(/\b(?:const|let|var)\s+(\w+)\s*=\s*[^;\n]*\bpathname\b/g)].map(([, name]) => name), - ); - const isPathname = (expression: string) => /(?:^|\.)pathname$/.test(expression) || pathnameVars.has(expression); - const compared = [ - ...[...content.matchAll(new RegExp(String.raw`([\w.$]+)\s*===?\s*${quoted}`, 'g'))].map(([, e]) => e), - ...[...content.matchAll(new RegExp(String.raw`${quoted}\s*===?\s*([\w.$]+)`, 'g'))].map(([, e]) => e), - ]; - if (compared.some(isPathname)) return true; - const switchesOnPathname = [...content.matchAll(/\bswitch\s*\(\s*([\w.$]+)\s*\)/g)].some(([, e]) => isPathname(e)); - return switchesOnPathname && new RegExp(String.raw`\bcase\s+${quoted}`).test(content); -} +const ROUTE_DECLARATIONS = [ + String.raw`\bpath\s*[:=]\s*\{?\s*`, + String.raw`\w\s*===?\s*`, + String.raw`\bcase\s+`, + String.raw`\bcreate(?:File)?Route\(\s*`, +]; /** Whether a client-only app serves `signInPath`, from a route in its source or a static page. */ export async function hasClientSignInRoute(projectDir: string, signInPath: string): Promise { - const { sources } = await readClientSource(projectDir, getClientEnvPrefix(projectDir)); - return servesSignInRoute(projectDir, sources, signInPath); + return servesSignInRoute(projectDir, await readClientSource(projectDir, getClientEnvPrefix(projectDir)), signInPath); } async function servesSignInRoute(projectDir: string, sources: string[], signInPath: string): Promise { - if (sources.some((content) => declaresRoute(content, signInPath))) return true; + const quoted = String.raw`['"\`]${signInPath.replace(/\/$/, '')}/?['"\`]`; + // Also the reversed comparison, `'/login' === path`. + const route = new RegExp(String.raw`(?:${ROUTE_DECLARATIONS.join('|')})${quoted}|${quoted}\s*===?`); + if (sources.some((content) => route.test(content))) return true; // A static page at the path, e.g. login/index.html, that starts sign-in. const segment = signInPath.replace(/^\/|\/$/g, ''); const pages = await fg( @@ -378,108 +363,31 @@ async function servesSignInRoute(projectDir: string, sources: string[], signInPa const readOrEmpty = (path: string) => readFile(path, 'utf-8').catch(() => ''); const SCAN_CONCURRENCY = 32; -const MAX_BROWSER_FILES = 5_000; -const SOURCE_IGNORE = [ - '**/node_modules/**', - '**/dist/**', - '**/build/**', - '**/.*/**', - '**/__tests__/**', - '**/*.{spec,test}.*', -]; -const CODE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs']; -const SCRIPT_SRC = /]*?\bsrc\s*=\s*["']([^"']+)["']/gi; -const IMPORT_SPECIFIER = - /\b(?:import|export)\s+(?:[\w*{}\s,$]+\s+from\s+)?['"]([^'"]+)['"]|\b(?:import|require)\s*\(\s*['"]([^'"]+)['"]\s*\)/g; - -/** - * The project file an import or script src names, or undefined for a - * package, a URL, or nothing on disk. `/x` is the project root (Vite, plain - * pages); `@/` and `~/` are the usual aliases for src/. - */ -function resolveLocal(projectDir: string, fromFile: string, specifier: string): string | undefined { - let base: string; - if (specifier.startsWith('./') || specifier.startsWith('../')) - base = join(dirname(join(projectDir, fromFile)), specifier); - else if (specifier.startsWith('/')) base = join(projectDir, specifier); - else if (specifier.startsWith('@/') || specifier.startsWith('~/')) base = join(projectDir, 'src', specifier.slice(2)); - else return undefined; - base = base.replace(/[?#].*$/, ''); - const candidates = [ - base, - ...CODE_EXTENSIONS.map((ext) => base + ext), - ...CODE_EXTENSIONS.map((ext) => join(base, `index${ext}`)), - // TypeScript ESM names the emitted file: './auth.js' is auth.ts on disk. - ...(/\.jsx?$/.test(base) ? [base.replace(/\.jsx?$/, '.ts'), base.replace(/\.jsx?$/, '.tsx')] : []), - ]; - const found = candidates.find( - (path) => /\.(?:m?[jt]sx?|html?)$/.test(path) && existsSync(path) && statSync(path).isFile(), - ); - const file = found && relative(projectDir, found); - return file && !file.startsWith('..') && !file.split(/[\\/]/).includes('node_modules') ? file : undefined; -} /** - * The code the browser loads: every HTML page with its scripts (Vite's - * index.html, a plain app's pages) or Create React App's src/index, and - * everything those import. A server, a script, or the bundler config is never - * reached from there, wherever it sits. Undefined when there is no entry. + * The app's browser code, read a bounded batch at a time. Create React App + * compiles only src/; elsewhere, leave out the bundler config at the project + * root. Tests do not serve routes. */ -async function readBrowserCode(projectDir: string, prefix: ReturnType) { - const entries = await fg([prefix === 'REACT_APP_' ? 'src/index.{js,jsx,ts,tsx}' : '**/*.{html,htm}'], { - cwd: projectDir, - ignore: SOURCE_IGNORE, - }); - if (entries.length === 0) return undefined; - const seen = new Set(entries); - const queue = [...entries]; - const sources: string[] = []; - while (queue.length > 0) { - const batch = queue.splice(0, SCAN_CONCURRENCY); - const contents = await Promise.all(batch.map((file) => readOrEmpty(join(projectDir, file)))); - batch.forEach((file, i) => { - const content = contents[i]; - sources.push(content); - const specifiers = /\.html?$/.test(file) - ? [...content.matchAll(SCRIPT_SRC)] - .map(([, src]) => src) - .filter((src) => !/^(?:[a-z]+:)?\/\//i.test(src)) - .map((src) => (/^[./]/.test(src) ? src : `./${src}`)) - : [...content.matchAll(IMPORT_SPECIFIER)].map(([, from, call]) => from ?? call); - for (const specifier of specifiers) { - const next = resolveLocal(projectDir, file, specifier); - if (next && !seen.has(next) && seen.size < MAX_BROWSER_FILES) { - seen.add(next); - queue.push(next); - } - } - }); - } - return sources; -} - -/** - * The app's browser code: traced from its entry when it has one (see - * readBrowserCode), else every source file, read a bounded batch at a time. - * Create React App compiles only src/; elsewhere, leave out the bundler - * config at the project root. Tests do not serve routes. - */ -async function readClientSource( - projectDir: string, - prefix: ReturnType, -): Promise<{ sources: string[]; traced: boolean }> { - const traced = await readBrowserCode(projectDir, prefix); - if (traced) return { sources: traced, traced: true }; +async function readClientSource(projectDir: string, prefix: ReturnType): Promise { const files = await fg([`${prefix === 'REACT_APP_' ? 'src/' : ''}**/*.{ts,tsx,js,jsx,mjs,html,htm}`], { cwd: projectDir, - ignore: [...SOURCE_IGNORE, '*.config.*'], + ignore: [ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/.*/**', + '**/__tests__/**', + '**/*.{spec,test}.*', + '*.config.*', + ], }); const contents: string[] = []; for (let i = 0; i < files.length; i += SCAN_CONCURRENCY) contents.push( ...(await Promise.all(files.slice(i, i + SCAN_CONCURRENCY).map((file) => readOrEmpty(join(projectDir, file))))), ); - return { sources: contents, traced: false }; + return contents; } /** From 550ff0ee62b38e26e31f01effa8809d3c6a33189 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 21:00:20 -0500 Subject: [PATCH 22/34] ci: diagnose smoke authentication with a direct read-only request When authenticated command smoke fails, issue a single GET /connections using the same smoke credential and configured API host through curl instead of the compiled CLI. Discard the body, do not follow redirects, and log only HTTP status, curl exit status, and the request ID. Keep the original failed check red. --- .github/workflows/test.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3cf66e85..53d523fd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -81,6 +81,7 @@ jobs: # is a dedicated staging-environment API key; fork PRs receive no # secrets, so this skips there instead of failing. - name: Smoke test authenticated commands + id: authenticated-smoke env: WORKOS_API_KEY: ${{ secrets.WORKOS_SMOKE_API_KEY }} # Optional: set when the smoke key belongs to a non-default API host @@ -96,6 +97,34 @@ jobs: fi sh scripts/command-smoke.sh ./dist/workos + # Independent, read-only control when the CLI smoke fails: distinguish + # CLI credential handling from the API rejecting the CI credential. + # Never log the credential, response body, or full response headers. + - name: Diagnose smoke authentication without the CLI + if: ${{ failure() && steps.authenticated-smoke.outcome == 'failure' }} + env: + WORKOS_API_KEY: ${{ secrets.WORKOS_SMOKE_API_KEY }} + WORKOS_SMOKE_API_URL: ${{ secrets.WORKOS_SMOKE_API_URL }} + run: | + set +x + if [ -z "$WORKOS_API_KEY" ]; then + echo "Direct API probe skipped: smoke credential unavailable" + exit 0 + fi + headers=$(mktemp) + trap 'rm -f "$headers"' EXIT + base="${WORKOS_SMOKE_API_URL:-https://api.workos.com}" + curl_exit=0 + status=$(curl --silent --connect-timeout 10 --max-time 20 \ + --proto '=http,https' \ + --header "Authorization: Bearer $WORKOS_API_KEY" \ + --dump-header "$headers" --output /dev/null \ + --write-out '%{http_code}' \ + "${base%/}/connections?limit=1" 2>/dev/null) || curl_exit=$? + printf 'Direct GET /connections: HTTP %s; curl exit %s\n' "$status" "$curl_exit" + # No --location: do not follow redirects with credentials. + awk 'tolower($1) == "x-request-id:" { gsub(/\r/, "", $2); if ($2 ~ /^[A-Za-z0-9_-]+$/) print "API request ID: " $2 }' "$headers" + # Publish the generated packages to a throwaway local registry and run # the real user flows — `npx workos`, `npm install -g workos`, platform # selection, and the launcher's no-binary error path — from a hermetic From 08c998535c85a42683097a4ee35ba95090814d42 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 21:12:40 -0500 Subject: [PATCH 23/34] fix(install): bound client checks and exclude server-only sources Check conventional client roots without an import resolver, excluding server, API, function, script and other workspace paths. Flag Node env reads in Vite client code rather than globally exempting them. Restrict route evidence to supported router forms or actual pathname comparisons and require a sign-in call. Limit reads to 256 files, 256 KiB per file and 4 MiB total; incomplete scans cannot authorize saving a sign-in destination. Add regression cases for the review findings and previously broken inline/custom-alias layouts. --- src/lib/run-with-core.setup.spec.ts | 4 +- src/lib/run-with-core.ts | 2 +- src/lib/sign-in-route.ts | 2 +- src/lib/validation/client-source.spec.ts | 116 +++++++++++++++++ src/lib/validation/validator.spec.ts | 5 +- src/lib/validation/validator.ts | 151 ++++++++++++++++------- 6 files changed, 227 insertions(+), 53 deletions(-) create mode 100644 src/lib/validation/client-source.spec.ts diff --git a/src/lib/run-with-core.setup.spec.ts b/src/lib/run-with-core.setup.spec.ts index 9e4ae1f8..bdcf7a4b 100644 --- a/src/lib/run-with-core.setup.spec.ts +++ b/src/lib/run-with-core.setup.spec.ts @@ -410,7 +410,9 @@ describe('application URLs for SDKs other than Next.js', () => { emitter.on('app-urls:step', ({ step, status, detail }) => events.push(`${step} ${status} ${detail ?? ''}`.trim())); await configureOtherApplicationUrls({ options, integration: 'react', emitter }, 'client_a', 'sk_test_a'); expect(vi.mocked(configureAuthkitApplication).mock.calls[0][0]).not.toHaveProperty('initiateLoginUri'); - expect(events).toContain('initiate-login-uri skipped The app has no /login route that starts sign-in.'); + expect(events).toContain( + 'initiate-login-uri skipped Could not confirm a /login client route that starts sign-in. Check the route before setting the Initiate login URI.', + ); }); it('shows an unusable callback URL in the checklist instead of dropping it', async () => { diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index 5c314299..06b1545e 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -308,7 +308,7 @@ export async function configureOtherApplicationUrls( // A client-only route has no file the guide fixes, so save it only once the app serves it. const clientOnly = (await getRegistry()).get(integration)?.config.environment.requiresApiKey === false; if (signInPath && clientOnly && !(await hasClientSignInRoute(installerOptions.installDir, signInPath))) { - initiateLoginReason = `The app has no ${signInPath} route that starts sign-in.`; + initiateLoginReason = `Could not confirm a ${signInPath} client route that starts sign-in. Check the route before setting the Initiate login URI.`; signInPath = undefined; } let setup: AuthkitApplicationSetup; diff --git a/src/lib/sign-in-route.ts b/src/lib/sign-in-route.ts index a0cb1e1d..ed765f0d 100644 --- a/src/lib/sign-in-route.ts +++ b/src/lib/sign-in-route.ts @@ -18,7 +18,7 @@ export function buildSignInSection({ if (!environment.requiresApiKey) { lines.push( '', - `This is a client-only app, so add a ${signInPath} client route that calls the SDK's signIn() as soon as the AuthKit client is ready, without a click (for example, in a useEffect). AuthKit keeps password-reset and invitation details through this redirect, so signIn() needs no extra arguments. If the app has a client router, register it there (for example path: '${signInPath}' or ). If it has none, compare window.location.pathname === '${signInPath}' at startup. The installer checks for one of these forms before it saves the Initiate login URI. Keep the existing sign-in button. Do not add a server route.`, + `This is a client-only app, so add a ${signInPath} client route that calls the SDK's signIn() as soon as the AuthKit client is ready, without a click (for example, in a useEffect). AuthKit keeps password-reset and invitation details through this redirect, so signIn() needs no extra arguments. If the app has a client router, register it there (for example { path: '${signInPath}', component: Login } or }>; Login must start sign-in on load). If it has none, compare window.location.pathname === '${signInPath}' at startup. The installer checks for one of these forms before it saves the Initiate login URI. Keep the existing sign-in button. Do not add a server route.`, '', "The installer registers WORKOS_REDIRECT_URI as the app's Redirect URI. The SDK defaults to the page origin instead, so pass that value explicitly. The installer also writes it to .env.local under the bundler's env prefix (VITE_WORKOS_REDIRECT_URI for Vite, REACT_APP_WORKOS_REDIRECT_URI for Create React App), with the client ID beside it. Read that variable and set it as redirectUri on AuthKitProvider or createClient().", ); diff --git a/src/lib/validation/client-source.spec.ts b/src/lib/validation/client-source.spec.ts new file mode 100644 index 00000000..b717697f --- /dev/null +++ b/src/lib/validation/client-source.spec.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { hasClientSignInRoute, validateInstallation } from './validator.js'; + +let project: string; +async function file(path: string, content: string) { + await mkdir(dirname(join(project, path)), { recursive: true }); + await writeFile(join(project, path), content); +} +const login = "if (window.location.pathname === '/login') authkit.signIn();"; +const client = 'createClient(id, { redirectUri: import.meta.env.VITE_WORKOS_REDIRECT_URI });'; +beforeEach(async () => { + project = await mkdtemp(join(tmpdir(), 'client-source-')); + await file('package.json', JSON.stringify({ devDependencies: { vite: '^6.0.0' } })); +}); +afterEach(async () => { + await rm(project, { recursive: true, force: true }); +}); + +it('rejects an unprefixed Node env read in Vite browser code', async () => { + await file('src/main.ts', 'createClient(id, { redirectUri: process.env.WORKOS_REDIRECT_URI });'); + const result = await validateInstallation('vanilla-js', project, { runBuild: false }); + expect(result.issues).toContainEqual( + expect.objectContaining({ + severity: 'error', + message: + 'The client reads process.env.WORKOS_REDIRECT_URI, but the installer exposes import.meta.env.VITE_WORKOS_REDIRECT_URI', + }), + ); +}); + +it.each([ + 'server.js', + 'src/server.ts', + 'src/server/auth.ts', + 'scripts/setup.ts', + 'api/auth.ts', + 'functions/auth.ts', + 'src/auth.server.ts', +])('does not let server-only %s satisfy browser route or callback checks', async (path) => { + await file('src/main.ts', 'const app = {};'); + await file(path, `${login}\nconst redirectUri = process.env.WORKOS_REDIRECT_URI;`); + const result = await validateInstallation('vanilla-js', project, { runBuild: false }); + expect(await hasClientSignInRoute(project, '/login')).toBe(false); + expect(result.issues.some((issue) => issue.message.includes('/login route'))).toBe(true); + expect(result.issues.some((issue) => issue.message === 'The AuthKit client does not set redirectUri')).toBe(true); + expect(result.issues.some((issue) => issue.message.startsWith('The client reads'))).toBe(false); +}); + +it.each([ + "if (redirectTarget === '/login') authkit.signIn();", + "if ('/login' === redirectTarget) authkit.signIn();", + "switch(window.location.pathname) {case '/': home();} switch(action) {case '/login': authkit.signIn();}", + "const links = [{ path: '/login', label: 'Sign in' }]; authkit.signIn();", + "const path = '/login'; authkit.signIn();", + 'Hello} />', +])('does not mistake unrelated code for a sign-in route: %s', async (source) => { + await file('src/main.tsx', source); + expect(await hasClientSignInRoute(project, '/login')).toBe(false); +}); + +it.each([ + login, + "const current = window.location.pathname; if(current === '/login') authkit.signIn();", + "if ('/login' === location.pathname) authkit.signIn();", + '} />; function Login() { signIn(); }', + "const routes = [{path: '/login', component: Login}]; function Login() {signIn();}", +])('accepts supported route evidence and a sign-in call: %s', async (source) => { + await file('src/auth.config.tsx', source); + expect(await hasClientSignInRoute(project, '/login')).toBe(true); +}); + +it('does not need to resolve inline imports or custom aliases to inspect conventional client files', async () => { + await file('index.html', ''); + await file('src/main.ts', "import '@features/login';"); + await file('src/features/login.ts', `${login}\n${client}`); + expect(await hasClientSignInRoute(project, '/login')).toBe(true); + const result = await validateInstallation('vanilla-js', project, { runBuild: false }); + expect(result.issues.some((issue) => issue.message.includes('/login route'))).toBe(false); + expect(result.issues.some((issue) => issue.message === 'The AuthKit client does not set redirectUri')).toBe(false); +}); + +it('does not scan other workspaces as this app', async () => { + await file('src/main.ts', client); + await file('packages/another-app/src/main.ts', login); + expect(await hasClientSignInRoute(project, '/login')).toBe(false); +}); + +describe('bounded scan', () => { + async function incomplete() { + expect(await hasClientSignInRoute(project, '/login')).toBe(false); + const result = await validateInstallation('vanilla-js', project, { runBuild: false }); + expect(result.issues).toContainEqual( + expect.objectContaining({ + severity: 'warning', + message: 'Client source checks were incomplete; automatic sign-in URL setup will be skipped', + }), + ); + } + it('does not authorize a route from a truncated file set', async () => { + await file('src/login.ts', login); + await Promise.all(Array.from({ length: 256 }, (_, i) => file(`src/file-${i}.ts`, 'export {};'))); + await incomplete(); + }); + it('bounds individual file reads', async () => { + await file('src/main.ts', login + ' '.repeat(256 * 1024)); + await incomplete(); + }); + it('bounds aggregate content, not only individual files', async () => { + await file('src/login.ts', login); + await Promise.all(Array.from({ length: 17 }, (_, i) => file(`src/file-${i}.ts`, ' '.repeat(256 * 1024)))); + await incomplete(); + }); +}); diff --git a/src/lib/validation/validator.spec.ts b/src/lib/validation/validator.spec.ts index 2f0db18c..2f42e7bd 100644 --- a/src/lib/validation/validator.spec.ts +++ b/src/lib/validation/validator.spec.ts @@ -768,6 +768,7 @@ describe('validateInstallation', () => { it('accepts a React SPA whose source serves /login', async () => { mkdirSync(join(testDir, 'src'), { recursive: true }); writeFileSync(join(testDir, 'src', 'App.tsx'), '} />'); + writeFileSync(join(testDir, 'src', 'Login.tsx'), 'function Login() { useEffect(() => { signIn(); }, []); }'); const result = await validateInstallation('react', testDir, { runBuild: false }); @@ -793,7 +794,7 @@ describe('validateInstallation', () => { }); it.each([ - "const path = window.location.pathname;\nif (path === '/login') return ;", + "const path = window.location.pathname;\nif (path === '/login') signIn();", "if ('/login' === location.pathname) signIn();", ])('accepts a pathname check through any variable: %s', async (code) => { mkdirSync(join(testDir, 'src'), { recursive: true }); @@ -818,7 +819,7 @@ describe('validateInstallation', () => { mkdirSync(join(testDir, 'src'), { recursive: true }); writeFileSync( join(testDir, 'src', 'auth.config.ts'), - "export const routes = [{ path: '/login' }];\nexport const redirectUri = import.meta.env.VITE_WORKOS_REDIRECT_URI;", + "export const routes = [{ path: '/login', component: Login }];\nfunction Login() { signIn(); }\nexport const redirectUri = import.meta.env.VITE_WORKOS_REDIRECT_URI;", ); const result = await validateInstallation('react', testDir, { runBuild: false }); diff --git a/src/lib/validation/validator.ts b/src/lib/validation/validator.ts index 7f5c7d86..7bfd5993 100644 --- a/src/lib/validation/validator.ts +++ b/src/lib/validation/validator.ts @@ -1,4 +1,4 @@ -import { readFile } from 'fs/promises'; +import { open, readFile } from 'fs/promises'; import { existsSync } from 'fs'; import { join } from 'path'; import fg from 'fast-glob'; @@ -285,7 +285,16 @@ export async function validateFrameworkSpecific(framework: string, projectDir: s async function validateClientOnlyApp(framework: string, projectDir: string, issues: ValidationIssue[]) { const signInPath = getSignInPath(framework); const prefix = getClientEnvPrefix(projectDir); - const sources = await readClientSource(projectDir, prefix); + const { sources, complete } = await readClientSource(projectDir, prefix); + if (!complete) { + issues.push({ + type: 'file', + severity: 'warning', + message: 'Client source checks were incomplete; automatic sign-in URL setup will be skipped', + hint: 'The check is limited to 256 source files, 256 KiB per file, and 4 MiB total. Check the sign-in route and callback manually.', + }); + return; + } if (signInPath && !(await servesSignInRoute(projectDir, sources, signInPath))) issues.push({ type: 'file', @@ -306,9 +315,6 @@ async function validateClientOnlyApp(framework: string, projectDir: string, issu sources.flatMap((content) => [...content.matchAll(REDIRECT_ENV_REFERENCE)].map(([read]) => read)), ); reads.delete(expected); - // Node code reads the unprefixed var. In Vite browser code the same read throws - // (no process global), so only Create React App needs it flagged. - if (prefix === 'VITE_') reads.delete('process.env.WORKOS_REDIRECT_URI'); for (const read of reads) issues.push({ type: 'env', @@ -328,66 +334,115 @@ const BROWSER_REDIRECT_ENV = { const REDIRECT_ENV_REFERENCE = /(?:import\.meta\.env|process\.env)\.\w*WORKOS_REDIRECT_URI\b/g; /** - * Code that serves a route rather than linking to it: router config, or a - * pathname check such as `path === '/login'` or `case '/login':`. Keep in step - * with the forms the agent prompt names (buildSignInSection). + * Conservative source evidence, not a browser-flow test. Recognize the forms + * named in buildSignInSection; arbitrary comparisons, switch cases and menu + * data are not routes. Unsupported forms remain a manual setup step. */ -const ROUTE_DECLARATIONS = [ - String.raw`\bpath\s*[:=]\s*\{?\s*`, - String.raw`\w\s*===?\s*`, - String.raw`\bcase\s+`, - String.raw`\bcreate(?:File)?Route\(\s*`, -]; - -/** Whether a client-only app serves `signInPath`, from a route in its source or a static page. */ +function declaresClientRoute(content: string, quoted: string): boolean { + const router = new RegExp( + String.raw`]*\bpath\s*=\s*\{?\s*${quoted}|\bcreateFileRoute\(\s*${quoted}|\bpath\s*:\s*${quoted}\s*,\s*(?:element|Component|component)\s*:`, + ); + if (router.test(content)) return true; + const aliases = [ + ...content.matchAll(/\b(?:const|let|var)\s+(\w+)\s*=\s*(?:window\.)?location\.pathname\s*(?:;|\n|$)/g), + ].map(([, name]) => name); + const pathname = String.raw`(? `|${name}`).join('')})(?![\w$])`; + return new RegExp(String.raw`${pathname}\s*===?\s*${quoted}|${quoted}\s*===?\s*${pathname}`).test(content); +} + +/** Whether the supported client source forms provide evidence of a sign-in route. */ export async function hasClientSignInRoute(projectDir: string, signInPath: string): Promise { - return servesSignInRoute(projectDir, await readClientSource(projectDir, getClientEnvPrefix(projectDir)), signInPath); + const { sources, complete } = await readClientSource(projectDir, getClientEnvPrefix(projectDir)); + return complete && servesSignInRoute(projectDir, sources, signInPath); } async function servesSignInRoute(projectDir: string, sources: string[], signInPath: string): Promise { - const quoted = String.raw`['"\`]${signInPath.replace(/\/$/, '')}/?['"\`]`; - // Also the reversed comparison, `'/login' === path`. - const route = new RegExp(String.raw`(?:${ROUTE_DECLARATIONS.join('|')})${quoted}|${quoted}\s*===?`); - if (sources.some((content) => route.test(content))) return true; + const escaped = signInPath.replace(/\/$/, '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const quoted = String.raw`['"\`]${escaped}/?['"\`]`; + // A route declaration without any SDK sign-in call is insufficient evidence. + if ( + sources.some((content) => /\bsignIn\s*\(/.test(content)) && + sources.some((content) => declaresClientRoute(content, quoted)) + ) + return true; // A static page at the path, e.g. login/index.html, that starts sign-in. const segment = signInPath.replace(/^\/|\/$/g, ''); const pages = await fg( [`${segment}.html`, `${segment}/index.html`, `public/${segment}.html`, `public/${segment}/index.html`], { cwd: projectDir }, ); - return (await Promise.all(pages.map((page) => readOrEmpty(join(projectDir, page))))).some((content) => - content.includes('signIn'), - ); + for (const page of pages) { + const content = await readBoundedSource(join(projectDir, page), MAX_SOURCE_BYTES); + if (content !== undefined && /\bsignIn\s*\(/.test(content)) return true; + } + return false; } -const readOrEmpty = (path: string) => readFile(path, 'utf-8').catch(() => ''); +const MAX_SOURCE_FILES = 256; +const MAX_SOURCE_BYTES = 256 * 1024; +const MAX_SCAN_BYTES = 4 * 1024 * 1024; -const SCAN_CONCURRENCY = 32; +/** Read at most limit + 1 bytes, rather than loading a huge file before checking. */ +async function readBoundedSource(path: string, limit: number): Promise { + try { + const handle = await open(path, 'r'); + try { + const bytes = Buffer.alloc(limit + 1); + let length = 0; + while (length < bytes.length) { + const { bytesRead } = await handle.read(bytes, length, bytes.length - length, null); + if (bytesRead === 0) break; + length += bytesRead; + } + return length > limit ? undefined : bytes.subarray(0, length).toString('utf8'); + } finally { + await handle.close(); + } + } catch { + return undefined; + } +} /** - * The app's browser code, read a bounded batch at a time. Create React App - * compiles only src/; elsewhere, leave out the bundler config at the project - * root. Tests do not serve routes. + * Bounded checks of conventional client source roots, not an import resolver. + * Server-only paths and other workspace packages are deliberately excluded. + * Keep src/auth.config.ts: unlike the root bundler config, it can be browser code. + * Any truncated/unreadable scan cannot authorize saving the sign-in destination. */ -async function readClientSource(projectDir: string, prefix: ReturnType): Promise { - const files = await fg([`${prefix === 'REACT_APP_' ? 'src/' : ''}**/*.{ts,tsx,js,jsx,mjs,html,htm}`], { - cwd: projectDir, - ignore: [ - '**/node_modules/**', - '**/dist/**', - '**/build/**', - '**/.*/**', - '**/__tests__/**', - '**/*.{spec,test}.*', - '*.config.*', - ], - }); - const contents: string[] = []; - for (let i = 0; i < files.length; i += SCAN_CONCURRENCY) - contents.push( - ...(await Promise.all(files.slice(i, i + SCAN_CONCURRENCY).map((file) => readOrEmpty(join(projectDir, file))))), - ); - return contents; +async function readClientSource( + projectDir: string, + prefix: ReturnType, +): Promise<{ sources: string[]; complete: boolean }> { + const extension = '*.{ts,tsx,js,jsx,mjs,html,htm}'; + const files = fg.stream( + prefix === 'REACT_APP_' ? [`src/**/${extension}`] : [extension, `{src,app,client}/**/${extension}`], + { + cwd: projectDir, + followSymbolicLinks: false, + suppressErrors: false, + ignore: [ + '**/{node_modules,dist,build,coverage,server,api,functions,scripts,packages,examples,fixtures,__tests__}/**', + '**/.*/**', + '**/*.{spec,test,server}.*', + '**/server.*', + '*.config.*', + ], + }, + ); + const sources: string[] = []; + let remaining = MAX_SCAN_BYTES; + try { + for await (const file of files) { + if (sources.length >= MAX_SOURCE_FILES) return { sources, complete: false }; + const content = await readBoundedSource(join(projectDir, String(file)), Math.min(MAX_SOURCE_BYTES, remaining)); + if (content === undefined) return { sources, complete: false }; + remaining -= Buffer.byteLength(content); + sources.push(content); + } + } catch { + return { sources, complete: false }; + } + return { sources, complete: true }; } /** From 24fb78bfc3aec7ef695709b2418104502b71c656 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 21:12:40 -0500 Subject: [PATCH 24/34] fix(install): report incomplete URL setup without claiming verification Use the established explicit-or-unclaimed homepage policy in the API-only fallback too. Authorized writes no longer require the unsupported homepage GET to succeed; unknown existing values remain untouched with an honest reason. A missing initiate-login destination leaves setup unverified even when other writes succeed. Add real GET-404 and missing-route regression coverage. --- src/lib/authkit-application-setup.spec.ts | 119 ++++++++++++++++------ src/lib/authkit-application-setup.ts | 23 ++++- 2 files changed, 107 insertions(+), 35 deletions(-) diff --git a/src/lib/authkit-application-setup.spec.ts b/src/lib/authkit-application-setup.spec.ts index dfc06105..2702ee7e 100644 --- a/src/lib/authkit-application-setup.spec.ts +++ b/src/lib/authkit-application-setup.spec.ts @@ -3,6 +3,10 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +vi.mock('./config-store.js', async (importOriginal) => ({ + ...(await importOriginal()), + getActiveEnvironment: vi.fn(), +})); vi.mock('./command-auth.js', () => ({ refreshIfExpired: vi.fn() })); vi.mock('./api-key.js', () => ({ resolveApiBaseUrl: () => 'https://api.workos.com', @@ -15,6 +19,7 @@ vi.mock('../catalog/operation.js', () => ({ resolveExecutableDocument: (operation: { name: string }) => operation.name, })); +import { getActiveEnvironment } from './config-store.js'; import { refreshIfExpired } from './command-auth.js'; import { fetchTeamEnvironments } from './environment-target.js'; import { dashboardGraphqlRequest } from './dashboard-graphql.js'; @@ -50,6 +55,13 @@ const writes = () => beforeEach(() => { vi.resetAllMocks(); + vi.stubGlobal( + 'fetch', + vi.fn(() => { + throw new Error('Unexpected network request'); + }), + ); + vi.mocked(getActiveEnvironment).mockReturnValue(null); vi.mocked(refreshIfExpired).mockResolvedValue({ accessToken: 'test-token', refreshed: false }); vi.mocked(fetchTeamEnvironments).mockResolvedValue([ { id: 'env_app', name: 'Sandbox', sandbox: true, clientId: setup.clientId }, @@ -192,8 +204,14 @@ describe('native application URL setup', () => { it('sets the sign-out URI and leaves the initiate login URI alone when the app has no sign-in route', async () => { const { initiateLoginUri: _omitted, ...withoutSignIn } = setup; + application.redirectUris = []; + delete application.appHomepageUrl; const result = await configureAuthkitApplication(withoutSignIn, setup.clientId); - expect(result.verified).toBe(true); + expect(result.verified).toBe(false); + expect(result.callbackRegistered).toBe(true); + expect(result.initiateLoginReason).toContain('No client sign-in route'); + expect(result.reason).toBe(result.initiateLoginReason); + expect(application.appHomepageUrl).toBe('http://localhost:4000'); expect(application.logoutUris).toContainEqual({ uri: setup.signOutUri, isDefault: true }); expect(application.initiateLoginUri).toBeNull(); for (const [name, options] of writes()) @@ -203,8 +221,12 @@ describe('native application URL setup', () => { it('does not report an existing initiate login URI as a conflict when the app has no sign-in route', async () => { application.initiateLoginUri = 'http://localhost:4000/login'; const { initiateLoginUri: _omitted, ...withoutSignIn } = setup; - const result = await configureAuthkitApplication(withoutSignIn, setup.clientId); - expect(result.verified).toBe(true); + const initiateLoginReason = 'The client sign-in route is missing.'; + const result = await configureAuthkitApplication({ ...withoutSignIn, initiateLoginReason }, setup.clientId); + expect(result.verified).toBe(false); + expect(result.initiateLoginReason).toBe(initiateLoginReason); + expect(result.reason).toBe(initiateLoginReason); + expect(result.reason).not.toContain('differs from this app'); expect(application.initiateLoginUri).toBe('http://localhost:4000/login'); }); @@ -214,9 +236,18 @@ describe('native application URL setup', () => { expect(application.appHomepageUrl).toBe('http://localhost:4000'); }); - it('fills an empty API-only homepage with the callback origin', async () => { + it('writes the API-only homepage for the matching stored unclaimed key despite GET 404', async () => { vi.mocked(refreshIfExpired).mockResolvedValue(null); - const request = vi.fn(async () => Response.json({ url: null })); + vi.mocked(getActiveEnvironment).mockReturnValue({ + name: 'Unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_unclaimed', + clientId: setup.clientId, + claimToken: 'claim', + }); + const request = vi.fn(async (_url: string, init: RequestInit) => + init.method === 'GET' ? new Response(null, { status: 404 }) : Response.json({}), + ); vi.stubGlobal('fetch', request); const result = await configureAuthkitApplication(setup, setup.clientId, 'sk_test_unclaimed'); expect(request).toHaveBeenCalledWith( @@ -232,44 +263,72 @@ describe('native application URL setup', () => { expect(dashboardGraphqlRequest).not.toHaveBeenCalled(); }); - it('preserves an existing API-only homepage unless explicitly overridden', async () => { - vi.mocked(refreshIfExpired).mockResolvedValue(null); - const request = vi.fn(async () => Response.json({ url: 'https://existing.example/' })); - vi.stubGlobal('fetch', request); - await configureAuthkitApplication(setup, setup.clientId, 'sk_test_unclaimed'); - expect(request).toHaveBeenCalledTimes(2); // Callback POST and homepage GET, no PUT. - request.mockClear(); - await configureAuthkitApplication( - { ...setup, homepageUrl: 'https://requested.example/' }, - setup.clientId, - 'sk_test_unclaimed', - ); - expect(request).toHaveBeenCalledWith( - 'https://api.workos.com/user_management/app_homepage_url', - expect.objectContaining({ method: 'PUT', body: JSON.stringify({ url: 'https://requested.example/' }) }), - ); - expect(dashboardGraphqlRequest).not.toHaveBeenCalled(); - }); + it.each(['unknown', 'claimed', 'different-unclaimed', 'unavailable'])( + 'skips the unknown API-only homepage for a %s key unless explicitly overridden', + async (kind) => { + vi.mocked(refreshIfExpired).mockResolvedValue(null); + if (kind === 'claimed') + vi.mocked(getActiveEnvironment).mockReturnValue({ + name: 'Sandbox', + type: 'sandbox', + apiKey: 'sk_test_unclaimed', + }); + if (kind === 'different-unclaimed') + vi.mocked(getActiveEnvironment).mockReturnValue({ + name: 'Unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_other', + clientId: setup.clientId, + claimToken: 'claim', + }); + if (kind === 'unavailable') + vi.mocked(getActiveEnvironment).mockImplementation(() => { + throw new Error('keyring unavailable'); + }); + const request = vi.fn(async (_url: string, init: RequestInit) => + init.method === 'GET' ? new Response(null, { status: 404 }) : Response.json({}), + ); + vi.stubGlobal('fetch', request); + const result = await configureAuthkitApplication(setup, setup.clientId, 'sk_test_unclaimed'); + expect(result).toMatchObject({ callbackRegistered: true, verified: false }); + expect(result.reason).toContain('Homepage URL was left unchanged'); + expect(result.reason).not.toContain('homepage setup failed'); + expect(request).toHaveBeenCalledTimes(1); // Callback POST only: no homepage GET or PUT. + request.mockClear(); + await configureAuthkitApplication( + { ...setup, homepageUrl: 'https://requested.example/' }, + setup.clientId, + 'sk_test_unclaimed', + ); + expect(request).toHaveBeenCalledWith( + 'https://api.workos.com/user_management/app_homepage_url', + expect.objectContaining({ method: 'PUT', body: JSON.stringify({ url: 'https://requested.example/' }) }), + ); + expect(dashboardGraphqlRequest).not.toHaveBeenCalled(); + }, + ); - it.each(['http', 'network', 'invalid-json', 'missing-url', 'put'])( + it.each(['http', 'network'])( 'reports API-only homepage %s failures without losing the callback or switching targets', async (failure) => { vi.mocked(refreshIfExpired).mockResolvedValue(null); const request = vi.fn(async (_url: string, init: RequestInit) => { if (init.method === 'POST') return Response.json({}); + if (init.method === 'GET') return new Response(null, { status: 404 }); if (failure === 'network') throw new Error('private details'); - if (failure === 'invalid-json') return new Response('invalid'); - if (failure === 'missing-url') return Response.json({}); - if (failure === 'put' && init.method === 'GET') return Response.json({ url: null }); return Response.json({ message: 'private details' }, { status: 403 }); }); vi.stubGlobal('fetch', request); - const result = await configureAuthkitApplication(setup, setup.clientId, 'sk_test_unclaimed'); + const result = await configureAuthkitApplication( + { ...setup, homepageUrl: 'https://requested.example/' }, + setup.clientId, + 'sk_test_unclaimed', + ); expect(result.callbackRegistered).toBe(true); expect(result.verified).toBe(false); expect(result.reason).toContain('homepage setup failed'); expect(result.reason).not.toContain('private details'); - expect(request.mock.calls.filter(([, init]) => init.method === 'PUT')).toHaveLength(failure === 'put' ? 1 : 0); + expect(request.mock.calls.filter(([, init]) => init.method === 'PUT')).toHaveLength(1); expect(dashboardGraphqlRequest).not.toHaveBeenCalled(); }, ); @@ -376,7 +435,7 @@ describe('native application URL setup', () => { const result = await configureAuthkitApplication(setup, setup.clientId, 'sk_test_team_a'); expect(result.callbackRegistered).toBe(true); expect(result.verified).toBe(false); - expect(request).toHaveBeenCalledTimes(3); + expect(request).toHaveBeenCalledTimes(1); for (const [, init] of request.mock.calls) { expect(init.headers).toMatchObject({ Authorization: 'Bearer sk_test_team_a' }); } diff --git a/src/lib/authkit-application-setup.ts b/src/lib/authkit-application-setup.ts index 4e36f428..34235df0 100644 --- a/src/lib/authkit-application-setup.ts +++ b/src/lib/authkit-application-setup.ts @@ -6,7 +6,7 @@ import { fetchTeamEnvironments } from './environment-target.js'; import { dashboardGraphqlRequest } from './dashboard-graphql.js'; import { getOperation, resolveExecutableDocument } from '../catalog/operation.js'; import { InstallDeclinedError } from './installer-errors.js'; -import { setHomepageUrl } from './workos-management.js'; +import { isUnclaimedEnvironmentKey, setHomepageUrl } from './workos-management.js'; import { getSignInPath } from './port-detection.js'; export interface AuthkitApplicationSetup { @@ -97,6 +97,14 @@ export async function configureAuthkitApplication( expectedClientId: string, apiKey?: string, ): Promise { + if (setup.initiateLoginUri === undefined) { + setup = { + ...setup, + initiateLoginReason: + setup.initiateLoginReason ?? + 'No client sign-in route is available. Add a sign-in route and configure the Initiate login URI in the dashboard.', + }; + } let callbackRegistered = false; const pending = (reason: string): AuthkitApplicationSetup => { if (!callbackRegistered) { @@ -131,10 +139,15 @@ export async function configureAuthkitApplication( return pending('Could not register the callback URL. Check the API key and connection, then retry setup.'); } callbackRegistered = true; + // REST cannot read the current homepage. Only an explicit override or the + // stored active unclaimed key authorizes replacing this single-valued setting. + if (setup.homepageUrl === undefined && !isUnclaimedEnvironmentKey(apiKey)) { + return pending( + 'Callback registered using the API key. Homepage URL was left unchanged because its current value cannot be read and this key does not match the stored active unclaimed environment. Supply --homepage-url to override it. Sign-out URI and Initiate login URI still require dashboard setup and verification.', + ); + } try { - await setHomepageUrl(apiKey, setup.homepageUrl ?? new URL(setup.redirectUri).origin, { - preserveExisting: setup.homepageUrl === undefined, - }); + await setHomepageUrl(apiKey, setup.homepageUrl ?? new URL(setup.redirectUri).origin); } catch { return pending( 'Callback registered using the API key, but homepage setup failed. Check the Homepage URL, Sign-out URI and Initiate login URI in the dashboard.', @@ -234,7 +247,7 @@ export async function configureAuthkitApplication( } callbackRegistered = true; - const reasons: string[] = []; + const reasons: string[] = setup.initiateLoginUri === undefined ? [setup.initiateLoginReason!] : []; const defaults = original.logoutUris.filter((uri) => uri.isDefault); const signOutConflict = defaults.length > 1 || defaults.some((uri) => !isSignOutDestination(uri.uri)); // True when this app has an initiate login URI that `uri` does not already hold. From 004093c5db83b7dbbdc2af0784b204860eb3711c Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 21:38:38 -0500 Subject: [PATCH 25/34] fix(install): tie sign-in evidence to the matching pathname branch Fail required validation when bounded scans are incomplete. Replace disconnected route/signIn string matches with parsed startup or React mount-effect branches: the matching positive pathname branch must directly call signIn. Ignore comments, strings, click callbacks and unrelated routes. Standardize this conservative source-evidence form in the client prompt; no import graph or bundler resolution. Promote the already-locked Babel parser to a direct dependency and add regression coverage. --- bun.lock | 1 + package.json | 1 + src/lib/agent-runner.spec.ts | 3 +- src/lib/sign-in-route.ts | 2 +- src/lib/validation/client-sign-in.spec.ts | 43 ++++++++ src/lib/validation/client-sign-in.ts | 129 ++++++++++++++++++++++ src/lib/validation/client-source.spec.ts | 15 ++- src/lib/validation/validator.spec.ts | 7 +- src/lib/validation/validator.ts | 33 +----- 9 files changed, 199 insertions(+), 35 deletions(-) create mode 100644 src/lib/validation/client-sign-in.spec.ts create mode 100644 src/lib/validation/client-sign-in.ts diff --git a/bun.lock b/bun.lock index 8396c481..bf0b6a76 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "~0.3.0", "@anthropic-ai/sdk": "^0.124.0", + "@babel/parser": "7.29.7", "@inkjs/ui": "^2.0.0", "@inquirer/prompts": "^8.5.2", "@napi-rs/keyring": "^1.2.0", diff --git a/package.json b/package.json index 3ee7876b..ce5cbd01 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "~0.3.0", "@anthropic-ai/sdk": "^0.124.0", + "@babel/parser": "7.29.7", "@inkjs/ui": "^2.0.0", "@inquirer/prompts": "^8.5.2", "@napi-rs/keyring": "^1.2.0", diff --git a/src/lib/agent-runner.spec.ts b/src/lib/agent-runner.spec.ts index 30a7fc34..4eb29faa 100644 --- a/src/lib/agent-runner.spec.ts +++ b/src/lib/agent-runner.spec.ts @@ -125,7 +125,8 @@ describe('installer prompt', () => { expect(prompt).toContain('## Sign-in route (Initiate login URI)'); expect(prompt).toContain('the app origin plus /login'); expect(prompt).toContain('add a /login client route'); - expect(prompt).toContain("compare window.location.pathname === '/login' at startup"); + expect(prompt).toContain("if (window.location.pathname === '/login') { void signIn(); }"); + expect(prompt).toContain('Keep the call directly in that branch, not in a click handler or another function.'); expect(prompt).toContain('VITE_WORKOS_REDIRECT_URI for Vite'); }); diff --git a/src/lib/sign-in-route.ts b/src/lib/sign-in-route.ts index ed765f0d..9f33f77e 100644 --- a/src/lib/sign-in-route.ts +++ b/src/lib/sign-in-route.ts @@ -18,7 +18,7 @@ export function buildSignInSection({ if (!environment.requiresApiKey) { lines.push( '', - `This is a client-only app, so add a ${signInPath} client route that calls the SDK's signIn() as soon as the AuthKit client is ready, without a click (for example, in a useEffect). AuthKit keeps password-reset and invitation details through this redirect, so signIn() needs no extra arguments. If the app has a client router, register it there (for example { path: '${signInPath}', component: Login } or }>; Login must start sign-in on load). If it has none, compare window.location.pathname === '${signInPath}' at startup. The installer checks for one of these forms before it saves the Initiate login URI. Keep the existing sign-in button. Do not add a server route.`, + `This is a client-only app, so add a ${signInPath} client route that calls the SDK's signIn() as soon as the AuthKit client is ready, without a click (for example, in a useEffect). AuthKit keeps password-reset and invitation details through this redirect, so signIn() needs no extra arguments. If the app has a client router, register the route there and keep it public. At startup (vanilla JS) or inside useEffect in a mounted component (React), use an explicit positive pathname guard: if (window.location.pathname === '${signInPath}') { void signIn(); }. Wait until AuthKit is ready; a readiness condition can be combined with the pathname guard using &&. Keep the call directly in that branch, not in a click handler or another function. The installer checks that this specific route starts sign-in before saving its URI; a route declaration alone is not enough. Keep the existing sign-in button. Do not add a server route.`, '', "The installer registers WORKOS_REDIRECT_URI as the app's Redirect URI. The SDK defaults to the page origin instead, so pass that value explicitly. The installer also writes it to .env.local under the bundler's env prefix (VITE_WORKOS_REDIRECT_URI for Vite, REACT_APP_WORKOS_REDIRECT_URI for Create React App), with the client ID beside it. Read that variable and set it as redirectUri on AuthKitProvider or createClient().", ); diff --git a/src/lib/validation/client-sign-in.spec.ts b/src/lib/validation/client-sign-in.spec.ts new file mode 100644 index 00000000..8e5a14b0 --- /dev/null +++ b/src/lib/validation/client-sign-in.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { hasClientSignInBehavior } from './client-sign-in.js'; + +describe('sign-in belongs to the matching pathname branch', () => { + it.each([ + "if (window.location.pathname === '/login') signIn();", + "if ('/login' === location.pathname) { void client.signIn(); }", + "const path = window.location.pathname; if (path === '/login') { await authkit.signIn(); }", + "function App() { useEffect(() => { if (!isLoading && window.location.pathname === '/login') { void signIn(); } }, [isLoading, signIn]); return null; }", + "function Login() { React.useEffect(() => { if (window.location.pathname === '/login') { signIn(); } }, []); return Signing in; }", + '', + ])('accepts route-bound startup behavior: %s', (source) => { + expect(hasClientSignInBehavior(source, '/login')).toBe(true); + }); + + it.each([ + 'Hello} />; const button = ;', + "if (window.location.pathname === '/login') renderPage(); signIn();", + "if (window.location.pathname === '/login') renderPage(); else signIn();", + "if (window.location.pathname === '/logout') signIn();", + "if (window.location.pathname !== '/login') signIn();", + "if (window.location.pathname === '/login' || debug) signIn();", + "if (window.location.pathname === '/login' && false) signIn();", + "if (window.location.pathname === '/login') { button.onclick = () => signIn(); }", + "if (window.location.pathname === '/login') { function click() { signIn(); } }", + "if (window.location.pathname === '/login') { return; signIn(); }", + "function onClick() { if (window.location.pathname === '/login') signIn(); }", + "// if (window.location.pathname === '/login') signIn();", + `const example = "if (window.location.pathname === '/login') signIn();";`, + "const pathname = '/login'; if (window.location[pathname] === '/login') signIn();", + "not valid JavaScript if (window.location.pathname === '/login') signIn();", + ])('rejects unrelated, unreachable or click-only calls: %s', (source) => { + expect(hasClientSignInBehavior(source, '/login')).toBe(false); + }); + + it('requires startup behavior for a static login page too', () => { + expect(hasClientSignInBehavior('', '/login', true)).toBe(true); + expect(hasClientSignInBehavior('', '/login', true)).toBe( + false, + ); + expect(hasClientSignInBehavior('', '/login', true)).toBe(false); + }); +}); diff --git a/src/lib/validation/client-sign-in.ts b/src/lib/validation/client-sign-in.ts new file mode 100644 index 00000000..169b018e --- /dev/null +++ b/src/lib/validation/client-sign-in.ts @@ -0,0 +1,129 @@ +import { parse } from '@babel/parser'; +import type { Node, Statement, Expression, MemberExpression } from '@babel/types'; + +function propertyName(node: Node | null | undefined): string | undefined { + if (node?.type === 'Identifier') return node.name; + if (node?.type === 'StringLiteral') return node.value; + return undefined; +} + +function memberName(node: MemberExpression): string | undefined { + return node.computed && node.property.type !== 'StringLiteral' ? undefined : propertyName(node.property); +} + +function isPathname(node: Node): boolean { + if (node.type !== 'MemberExpression' || memberName(node) !== 'pathname') return false; + const location = node.object; + return ( + (location.type === 'Identifier' && location.name === 'location') || + (location.type === 'MemberExpression' && + memberName(location) === 'location' && + location.object.type === 'Identifier' && + location.object.name === 'window') + ); +} + +function isSignInCall(node: Node | null | undefined): boolean { + if (node?.type === 'AwaitExpression' || (node?.type === 'UnaryExpression' && node.operator === 'void')) { + return isSignInCall(node.argument); + } + return ( + node?.type === 'CallExpression' && + ((node.callee.type === 'Identifier' && node.callee.name === 'signIn') || + (node.callee.type === 'MemberExpression' && memberName(node.callee) === 'signIn')) + ); +} + +/** Only directly executed statements, never a button callback or a nested function. */ +function startsSignIn(statements: Statement[]): boolean { + for (const statement of statements) { + if (statement.type === 'ExpressionStatement' && isSignInCall(statement.expression)) return true; + if (statement.type === 'ReturnStatement') return isSignInCall(statement.argument); + if (statement.type === 'ThrowStatement') return false; + } + return false; +} + +function guardsPath(test: Expression, path: string, aliases: Set): boolean { + if (test.type === 'LogicalExpression' && test.operator === '&&') { + if ([test.left, test.right].some((part) => part.type === 'BooleanLiteral' && !part.value)) return false; + return guardsPath(test.left, path, aliases) || guardsPath(test.right, path, aliases); + } + if (test.type !== 'BinaryExpression' || !['===', '=='].includes(test.operator)) return false; + const match = (value: Node, pathname: Node) => + value.type === 'StringLiteral' && + value.value === path && + (isPathname(pathname) || (pathname.type === 'Identifier' && aliases.has(pathname.name))); + return match(test.left, test.right) || match(test.right, test.left); +} + +/** Route condition and sign-in must be in the same executed branch. */ +function guardedSignIn(statements: Statement[], path: string): boolean { + const aliases = new Set(); + for (const statement of statements) { + if (statement.type === 'VariableDeclaration' && statement.kind === 'const') { + for (const declaration of statement.declarations) { + if (declaration.id.type === 'Identifier' && declaration.init && isPathname(declaration.init)) { + aliases.add(declaration.id.name); + } + } + } + if (statement.type === 'IfStatement' && guardsPath(statement.test, path, aliases)) { + const body = statement.consequent.type === 'BlockStatement' ? statement.consequent.body : [statement.consequent]; + if (startsSignIn(body)) return true; + } + if (statement.type === 'ReturnStatement' || statement.type === 'ThrowStatement') return false; + } + return false; +} + +function isNode(value: unknown): value is Node { + return typeof value === 'object' && value !== null && 'type' in value && typeof value.type === 'string'; +} + +function effectStartsSignIn(node: Node, path: string): boolean { + if (node.type === 'CallExpression') { + const name = node.callee.type === 'MemberExpression' ? memberName(node.callee) : propertyName(node.callee); + const callback = node.arguments[0]; + if ( + name === 'useEffect' && + (callback?.type === 'ArrowFunctionExpression' || callback?.type === 'FunctionExpression') && + callback.body.type === 'BlockStatement' && + guardedSignIn(callback.body.body, path) + ) + return true; + } + return Object.values(node).some((value) => { + if (Array.isArray(value)) return value.some((child) => isNode(child) && effectStartsSignIn(child, path)); + return isNode(value) && effectStartsSignIn(value, path); + }); +} + +/** + * Supported automatic setup contract: a pathname-guarded SDK call at startup + * (vanilla JS) or in a mount effect (React). Router declarations alone, imported + * components and click handlers are not proof. No module/import resolution. + * A static /login.html page can instead call signIn directly at startup. + * This is source evidence only; browser-flow testing remains a separate step. + */ +export function hasClientSignInBehavior(source: string, path: string, staticPage = false): boolean { + const scripts = /]*)>([\s\S]*?)<\/script\s*>/gi)] + .filter(([, attributes]) => !/\bsrc\s*=/i.test(attributes)) + .map(([, , body]) => body) + : [source]; + return scripts.some((script) => { + try { + const program = parse(script, { + sourceType: 'unambiguous', + plugins: ['typescript', 'jsx'], + allowReturnOutsideFunction: true, + }).program; + if (staticPage) return startsSignIn(program.body); + return guardedSignIn(program.body, path) || effectStartsSignIn(program, path); + } catch { + // Unparseable or unsupported source must not authorize a dashboard write. + return false; + } + }); +} diff --git a/src/lib/validation/client-source.spec.ts b/src/lib/validation/client-source.spec.ts index b717697f..7f0dd5b6 100644 --- a/src/lib/validation/client-source.spec.ts +++ b/src/lib/validation/client-source.spec.ts @@ -65,8 +65,8 @@ it.each([ login, "const current = window.location.pathname; if(current === '/login') authkit.signIn();", "if ('/login' === location.pathname) authkit.signIn();", - '} />; function Login() { signIn(); }', - "const routes = [{path: '/login', component: Login}]; function Login() {signIn();}", + "function Login() { useEffect(() => { if (window.location.pathname === '/login') signIn(); }, []); return null; }", + "function App() { useEffect(() => { if (!loading && window.location.pathname === '/login') void signIn(); }, [loading]); return null; }", ])('accepts supported route evidence and a sign-in call: %s', async (source) => { await file('src/auth.config.tsx', source); expect(await hasClientSignInRoute(project, '/login')).toBe(true); @@ -82,6 +82,14 @@ it('does not need to resolve inline imports or custom aliases to inspect convent expect(result.issues.some((issue) => issue.message === 'The AuthKit client does not set redirectUri')).toBe(false); }); +it('does not let a sign-in button in a different file qualify the login route', async () => { + await file('src/routes.tsx', 'Hello} />'); + await file('src/button.tsx', 'export const button = ;'); + expect(await hasClientSignInRoute(project, '/login')).toBe(false); + const result = await validateInstallation('react', project, { runBuild: false }); + expect(result.issues.some((issue) => issue.message.includes('/login route'))).toBe(true); +}); + it('does not scan other workspaces as this app', async () => { await file('src/main.ts', client); await file('packages/another-app/src/main.ts', login); @@ -92,9 +100,10 @@ describe('bounded scan', () => { async function incomplete() { expect(await hasClientSignInRoute(project, '/login')).toBe(false); const result = await validateInstallation('vanilla-js', project, { runBuild: false }); + expect(result.passed).toBe(false); expect(result.issues).toContainEqual( expect.objectContaining({ - severity: 'warning', + severity: 'error', message: 'Client source checks were incomplete; automatic sign-in URL setup will be skipped', }), ); diff --git a/src/lib/validation/validator.spec.ts b/src/lib/validation/validator.spec.ts index 2f42e7bd..26031b76 100644 --- a/src/lib/validation/validator.spec.ts +++ b/src/lib/validation/validator.spec.ts @@ -768,7 +768,10 @@ describe('validateInstallation', () => { it('accepts a React SPA whose source serves /login', async () => { mkdirSync(join(testDir, 'src'), { recursive: true }); writeFileSync(join(testDir, 'src', 'App.tsx'), '} />'); - writeFileSync(join(testDir, 'src', 'Login.tsx'), 'function Login() { useEffect(() => { signIn(); }, []); }'); + writeFileSync( + join(testDir, 'src', 'Login.tsx'), + "function Login() { useEffect(() => { if (window.location.pathname === '/login') signIn(); }, []); }", + ); const result = await validateInstallation('react', testDir, { runBuild: false }); @@ -819,7 +822,7 @@ describe('validateInstallation', () => { mkdirSync(join(testDir, 'src'), { recursive: true }); writeFileSync( join(testDir, 'src', 'auth.config.ts'), - "export const routes = [{ path: '/login', component: Login }];\nfunction Login() { signIn(); }\nexport const redirectUri = import.meta.env.VITE_WORKOS_REDIRECT_URI;", + "export const routes = [{ path: '/login', component: Login }];\nfunction Login() { useEffect(() => { if (window.location.pathname === '/login') signIn(); }, []); }\nexport const redirectUri = import.meta.env.VITE_WORKOS_REDIRECT_URI;", ); const result = await validateInstallation('react', testDir, { runBuild: false }); diff --git a/src/lib/validation/validator.ts b/src/lib/validation/validator.ts index 7bfd5993..2273b5a7 100644 --- a/src/lib/validation/validator.ts +++ b/src/lib/validation/validator.ts @@ -4,6 +4,7 @@ import { join } from 'path'; import fg from 'fast-glob'; import type { ValidationResult, ValidationRules, ValidationIssue } from './types.js'; import { runBuildValidation } from './build-validator.js'; +import { hasClientSignInBehavior } from './client-sign-in.js'; import { detectPort, getClientEnvPrefix, getSignInPath } from '../port-detection.js'; import { nextjsRoutePath, findNextjsSignInPage } from '../../integrations/nextjs/utils.js'; import nextjsRules from './rules/nextjs.json' with { type: 'json' }; @@ -289,7 +290,7 @@ async function validateClientOnlyApp(framework: string, projectDir: string, issu if (!complete) { issues.push({ type: 'file', - severity: 'warning', + severity: 'error', message: 'Client source checks were incomplete; automatic sign-in URL setup will be skipped', hint: 'The check is limited to 256 source files, 256 KiB per file, and 4 MiB total. Check the sign-in route and callback manually.', }); @@ -300,7 +301,7 @@ async function validateClientOnlyApp(framework: string, projectDir: string, issu type: 'file', severity: 'error', message: `No ${signInPath} route starts sign-in`, - hint: `Register a public ${signInPath} client route that calls the SDK's signIn() on load. A link to ${signInPath} is not a route. The installer saves it as the Initiate login URI.`, + hint: `At startup or in a mount effect, call signIn() inside an if (window.location.pathname === '${signInPath}') branch once AuthKit is ready. A route declaration or unrelated sign-in button is not enough.`, }); if (!sources.some((content) => content.includes('redirectUri'))) issues.push({ @@ -333,23 +334,6 @@ const BROWSER_REDIRECT_ENV = { /** A redirect URI env read, e.g. import.meta.env.VITE_WORKOS_REDIRECT_URI. */ const REDIRECT_ENV_REFERENCE = /(?:import\.meta\.env|process\.env)\.\w*WORKOS_REDIRECT_URI\b/g; -/** - * Conservative source evidence, not a browser-flow test. Recognize the forms - * named in buildSignInSection; arbitrary comparisons, switch cases and menu - * data are not routes. Unsupported forms remain a manual setup step. - */ -function declaresClientRoute(content: string, quoted: string): boolean { - const router = new RegExp( - String.raw`]*\bpath\s*=\s*\{?\s*${quoted}|\bcreateFileRoute\(\s*${quoted}|\bpath\s*:\s*${quoted}\s*,\s*(?:element|Component|component)\s*:`, - ); - if (router.test(content)) return true; - const aliases = [ - ...content.matchAll(/\b(?:const|let|var)\s+(\w+)\s*=\s*(?:window\.)?location\.pathname\s*(?:;|\n|$)/g), - ].map(([, name]) => name); - const pathname = String.raw`(? `|${name}`).join('')})(?![\w$])`; - return new RegExp(String.raw`${pathname}\s*===?\s*${quoted}|${quoted}\s*===?\s*${pathname}`).test(content); -} - /** Whether the supported client source forms provide evidence of a sign-in route. */ export async function hasClientSignInRoute(projectDir: string, signInPath: string): Promise { const { sources, complete } = await readClientSource(projectDir, getClientEnvPrefix(projectDir)); @@ -357,14 +341,7 @@ export async function hasClientSignInRoute(projectDir: string, signInPath: strin } async function servesSignInRoute(projectDir: string, sources: string[], signInPath: string): Promise { - const escaped = signInPath.replace(/\/$/, '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const quoted = String.raw`['"\`]${escaped}/?['"\`]`; - // A route declaration without any SDK sign-in call is insufficient evidence. - if ( - sources.some((content) => /\bsignIn\s*\(/.test(content)) && - sources.some((content) => declaresClientRoute(content, quoted)) - ) - return true; + if (sources.some((content) => hasClientSignInBehavior(content, signInPath))) return true; // A static page at the path, e.g. login/index.html, that starts sign-in. const segment = signInPath.replace(/^\/|\/$/g, ''); const pages = await fg( @@ -373,7 +350,7 @@ async function servesSignInRoute(projectDir: string, sources: string[], signInPa ); for (const page of pages) { const content = await readBoundedSource(join(projectDir, page), MAX_SOURCE_BYTES); - if (content !== undefined && /\bsignIn\s*\(/.test(content)) return true; + if (content !== undefined && hasClientSignInBehavior(content, signInPath, true)) return true; } return false; } From 22a59b3444f2f8c3a2ec48c823db0593e0dc0add Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 21:38:39 -0500 Subject: [PATCH 26/34] ci: distinguish unusable smoke credentials from CLI regressions Preflight live smoke with one direct read-only API request. Missing credentials or direct HTTP401 report live checks NOT RUN with an explicit warning/summary instead of misclassifying a CLI regression. HTTP200 enables the unchanged authenticated CLI checks, whose failures still fail CI. All other HTTP or transport failures remain fatal. Localhost-only tests cover status handling, non-disclosure, no redirects, and propagation of CLI failures. --- .github/workflows/test.yml | 45 ++----- scripts/smoke-auth-preflight.sh | 47 +++++++ scripts/smoke-auth-preflight.spec.ts | 184 +++++++++++++++++++++++++++ 3 files changed, 242 insertions(+), 34 deletions(-) create mode 100644 scripts/smoke-auth-preflight.sh create mode 100644 scripts/smoke-auth-preflight.spec.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 53d523fd..41ba4e72 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -78,53 +78,30 @@ jobs: # Authenticated command execution against a real WorkOS staging # environment: list plus a create → get → delete round-trip. The secret - # is a dedicated staging-environment API key; fork PRs receive no - # secrets, so this skips there instead of failing. + # is a dedicated staging-environment API key. Only a successful direct + # read-only probe enables live checks; missing/rejected credentials are + # reported as NOT RUN, while other preflight failures fail the job. + - name: Preflight smoke authentication without the CLI + id: smoke-auth-preflight + env: + WORKOS_SMOKE_API_KEY: ${{ secrets.WORKOS_SMOKE_API_KEY }} + WORKOS_SMOKE_API_URL: ${{ secrets.WORKOS_SMOKE_API_URL }} + run: sh scripts/smoke-auth-preflight.sh + - name: Smoke test authenticated commands id: authenticated-smoke + if: steps.smoke-auth-preflight.outputs.usable == 'true' env: WORKOS_API_KEY: ${{ secrets.WORKOS_SMOKE_API_KEY }} # Optional: set when the smoke key belongs to a non-default API host # (e.g. an internal staging deployment). WORKOS_SMOKE_API_URL: ${{ secrets.WORKOS_SMOKE_API_URL }} run: | - if [ -z "$WORKOS_API_KEY" ]; then - echo "Skipped: WORKOS_SMOKE_API_KEY secret not available (fork PR or not yet configured)" - exit 0 - fi if [ -n "$WORKOS_SMOKE_API_URL" ]; then export WORKOS_API_URL="$WORKOS_SMOKE_API_URL" fi sh scripts/command-smoke.sh ./dist/workos - # Independent, read-only control when the CLI smoke fails: distinguish - # CLI credential handling from the API rejecting the CI credential. - # Never log the credential, response body, or full response headers. - - name: Diagnose smoke authentication without the CLI - if: ${{ failure() && steps.authenticated-smoke.outcome == 'failure' }} - env: - WORKOS_API_KEY: ${{ secrets.WORKOS_SMOKE_API_KEY }} - WORKOS_SMOKE_API_URL: ${{ secrets.WORKOS_SMOKE_API_URL }} - run: | - set +x - if [ -z "$WORKOS_API_KEY" ]; then - echo "Direct API probe skipped: smoke credential unavailable" - exit 0 - fi - headers=$(mktemp) - trap 'rm -f "$headers"' EXIT - base="${WORKOS_SMOKE_API_URL:-https://api.workos.com}" - curl_exit=0 - status=$(curl --silent --connect-timeout 10 --max-time 20 \ - --proto '=http,https' \ - --header "Authorization: Bearer $WORKOS_API_KEY" \ - --dump-header "$headers" --output /dev/null \ - --write-out '%{http_code}' \ - "${base%/}/connections?limit=1" 2>/dev/null) || curl_exit=$? - printf 'Direct GET /connections: HTTP %s; curl exit %s\n' "$status" "$curl_exit" - # No --location: do not follow redirects with credentials. - awk 'tolower($1) == "x-request-id:" { gsub(/\r/, "", $2); if ($2 ~ /^[A-Za-z0-9_-]+$/) print "API request ID: " $2 }' "$headers" - # Publish the generated packages to a throwaway local registry and run # the real user flows — `npx workos`, `npm install -g workos`, platform # selection, and the launcher's no-binary error path — from a hermetic diff --git a/scripts/smoke-auth-preflight.sh b/scripts/smoke-auth-preflight.sh new file mode 100644 index 00000000..17eed0ba --- /dev/null +++ b/scripts/smoke-auth-preflight.sh @@ -0,0 +1,47 @@ +#!/bin/sh +# Read-only credential eligibility check. Never log credentials or API payloads. +set +x +set -eu + +report() { + printf '%s\n' "$2" + if [ -n "${GITHUB_OUTPUT:-}" ]; then + printf 'usable=%s\n' "$1" >> "$GITHUB_OUTPUT" + fi + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + printf '%s\n' "$2" >> "$GITHUB_STEP_SUMMARY" + fi +} + +if [ -z "${WORKOS_SMOKE_API_KEY:-}" ]; then + report false 'Live authenticated checks NOT RUN: WORKOS_SMOKE_API_KEY is missing. Credential/API configuration needs investigation (fork PRs may have no secrets).' + exit 0 +fi + +base="${WORKOS_SMOKE_API_URL:-https://api.workos.com}" +curl_exit=0 +# -q must be first: ignore curlrc (including redirects, retries and tracing). +# No --location, retries, response headers, body, or curl diagnostics. +status=$(curl -q --silent --connect-timeout 10 --max-time 20 \ + --proto '=http,https' --request GET \ + --header "Authorization: Bearer $WORKOS_SMOKE_API_KEY" \ + --output /dev/null --write-out '%{http_code}' \ + "${base%/}/connections?limit=1" 2>/dev/null) || curl_exit=$? + +if [ "$curl_exit" -ne 0 ]; then + report false '::error::Smoke credential preflight transport failure. Live authenticated checks NOT RUN; investigate credential/API connectivity.' + exit 1 +fi + +case "$status" in + 200) + report true 'Smoke credential preflight HTTP 200: eligible for live authenticated checks; this is not a live smoke pass.' + ;; + 401) + report false '::warning::Smoke credential preflight HTTP 401: live authenticated checks NOT RUN. Investigate the CI credential/API configuration; live checks have not passed.' + ;; + *) + report false "::error::Smoke credential preflight HTTP $status (expected 200 or 401). Live authenticated checks NOT RUN; investigate the API response." + exit 1 + ;; +esac diff --git a/scripts/smoke-auth-preflight.spec.ts b/scripts/smoke-auth-preflight.spec.ts new file mode 100644 index 00000000..d05a7553 --- /dev/null +++ b/scripts/smoke-auth-preflight.spec.ts @@ -0,0 +1,184 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { parse } from 'yaml'; + +const script = resolve('scripts/smoke-auth-preflight.sh'); +const key = 'fake-localhost-only-secret'; +const body = 'private-response-body'; +const header = 'private-response-header'; +const servers: Server[] = []; +const dirs: string[] = []; + +async function fixture(status: number, disconnect = false) { + const requests: { method?: string; url?: string; authorization?: string }[] = []; + const server = createServer((req, res) => { + requests.push({ method: req.method, url: req.url, authorization: req.headers.authorization }); + if (disconnect) { + req.socket.destroy(); + return; + } + res.writeHead(status, { Location: '/redirect-target', 'X-Private': header }); + res.end(`${body} ${key}`); + }); + servers.push(server); + await new Promise((done) => server.listen(0, '127.0.0.1', done)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing local address'); + return { url: `http://127.0.0.1:${address.port}`, requests }; +} + +async function run(url: string, credential = key, githubFiles = true) { + const dir = await mkdtemp(join(tmpdir(), 'smoke-preflight-')); + dirs.push(dir); + const output = join(dir, 'output'); + const summary = join(dir, 'summary'); + // A user curlrc must not enable redirects, retries, or verbose secret logging. + await writeFile(join(dir, '.curlrc'), 'location\nverbose\nretry = 2\n'); + await writeFile(output, ''); + await writeFile(summary, ''); + const result = await new Promise<{ code: number; stdout: string; stderr: string }>((done) => { + execFile( + 'sh', + [script], + { + timeout: 5000, + // Do not inherit credentials, proxy configuration or curl settings. + env: { + PATH: process.env.PATH, + HOME: dir, + CURL_HOME: dir, + NO_PROXY: '*', + WORKOS_SMOKE_API_KEY: credential, + WORKOS_SMOKE_API_URL: `${url}/`, + ...(githubFiles ? { GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary } : {}), + }, + }, + (error, stdout, stderr) => { + done({ code: error ? (typeof error.code === 'number' ? error.code : -1) : 0, stdout, stderr }); + }, + ); + }); + const outputs = await readFile(output, 'utf8'); + const notes = await readFile(summary, 'utf8'); + const logs = result.stdout + result.stderr + outputs + notes; + for (const sensitive of [key, body, header, 'Authorization:', 'X-Private:']) { + expect(logs).not.toContain(sensitive); + } + expect(result.stderr).toBe(''); + return { ...result, outputs, notes }; +} + +afterEach(async () => { + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((done, reject) => { + server.close((error) => (error ? reject(error) : done())); + }), + ), + ); + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('smoke authentication preflight (localhost only)', () => { + it('enables live smoke only for HTTP 200 with a single authenticated GET', async () => { + const local = await fixture(200); + const result = await run(local.url); + expect(result.code).toBe(0); + expect(result.outputs).toBe('usable=true\n'); + expect(result.notes).toContain('not a live smoke pass'); + expect(local.requests).toEqual([ + { + method: 'GET', + url: '/connections?limit=1', + authorization: `Bearer ${key}`, + }, + ]); + }); + + it('transparently skips rejected credentials with a GitHub warning and summary', async () => { + const local = await fixture(401); + const result = await run(local.url); + expect(result.code).toBe(0); + expect(result.outputs).toBe('usable=false\n'); + expect(result.stdout).toContain('::warning::'); + expect(result.notes).toContain('HTTP 401'); + expect(result.notes).toContain('NOT RUN'); + expect(result.notes).toContain('Investigate the CI credential/API'); + expect(local.requests).toHaveLength(1); + }); + + it('skips missing credentials without a request', async () => { + const local = await fixture(200); + const result = await run(local.url, ''); + expect(result.code).toBe(0); + expect(result.outputs).toBe('usable=false\n'); + expect(result.notes).toContain('NOT RUN'); + expect(result.notes).toContain('Credential/API configuration needs investigation'); + expect(local.requests).toHaveLength(0); + }); + + it.each([204, 301, 302, 307, 308, 403, 429, 500, 503])( + 'fails closed for HTTP %s, without following or retrying', + async (status) => { + const local = await fixture(status); + const result = await run(local.url); + expect(result.code).toBe(1); + expect(result.outputs).toBe('usable=false\n'); + expect(result.stdout).toContain('::error::'); + expect(result.notes).toContain('NOT RUN'); + expect(local.requests).toHaveLength(1); + expect(local.requests[0].url).toBe('/connections?limit=1'); + }, + ); + + it('fails red on transport failure', async () => { + const local = await fixture(200, true); + const result = await run(local.url); + expect(result.code).toBe(1); + expect(result.outputs).toBe('usable=false\n'); + expect(result.notes).toContain('transport failure'); + expect(local.requests).toHaveLength(1); + }); + + it('also works without GitHub output files', async () => { + const local = await fixture(200); + const result = await run(local.url, key, false); + expect(result.code).toBe(0); + expect(result.stdout).toContain('eligible'); + }); + + it('gates the existing live CLI smoke without suppressing its failures', async () => { + const workflow = parse(await readFile('.github/workflows/test.yml', 'utf8')); + const steps = workflow.jobs.test.steps; + const preflight = steps.findIndex((step: { id?: string }) => step.id === 'smoke-auth-preflight'); + const live = steps.findIndex((step: { id?: string }) => step.id === 'authenticated-smoke'); + expect(preflight).toBeGreaterThan(-1); + expect(live).toBeGreaterThan(preflight); + expect(steps[preflight].run).toBe('sh scripts/smoke-auth-preflight.sh'); + expect(steps[live].if).toBe("steps.smoke-auth-preflight.outputs.usable == 'true'"); + expect(steps[live]['continue-on-error']).toBeUndefined(); + expect(steps[preflight]['continue-on-error']).toBeUndefined(); + expect(steps[live].env.WORKOS_API_KEY).toBe(steps[preflight].env.WORKOS_SMOKE_API_KEY); + expect(steps[live].env.WORKOS_SMOKE_API_URL).toBe(steps[preflight].env.WORKOS_SMOKE_API_URL); + expect(steps[live].run.trim().endsWith('sh scripts/command-smoke.sh ./dist/workos')).toBe(true); + // Execute the workflow wrapper with a failing local CLI-smoke stand-in. + // No CLI, credentials, or network are used; its actual exit must survive. + const exitCode = await new Promise((done) => { + execFile( + 'sh', + ['-e', '-c', `sh() { return 17; };\n${steps[live].run}`], + { env: { PATH: process.env.PATH, WORKOS_SMOKE_API_URL: 'http://127.0.0.1:1' } }, + (error) => done(error && typeof error.code === 'number' ? error.code : 0), + ); + }); + expect(exitCode).toBe(17); + expect(steps.some((step: { name?: string }) => step.name === 'Diagnose smoke authentication without the CLI')).toBe( + false, + ); + }); +}); From ea46d36899235629f12eeb842d77a51698f5912e Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 22:26:00 -0500 Subject: [PATCH 27/34] fix(install): recognize sign-in effects bound to React login routes Tie JSX and object-route targets to their lexical component definitions and mount effects, including relative default/named exports from the bounded source set. A routed component does not need a redundant pathname guard. Reject unrelated, shadowed, reassigned, click-only and unresolved component bindings. Preserve the bounded scan and existing guarded startup path; no disk-expanding import traversal. --- src/lib/agent-runner.spec.ts | 2 + src/lib/sign-in-route.ts | 2 +- src/lib/validation/client-sign-in.spec.ts | 81 ++++++ src/lib/validation/client-sign-in.ts | 327 ++++++++++++++++++++-- src/lib/validation/client-source.spec.ts | 90 ++++++ src/lib/validation/validator.ts | 21 +- 6 files changed, 496 insertions(+), 27 deletions(-) diff --git a/src/lib/agent-runner.spec.ts b/src/lib/agent-runner.spec.ts index 4eb29faa..3be837f3 100644 --- a/src/lib/agent-runner.spec.ts +++ b/src/lib/agent-runner.spec.ts @@ -127,6 +127,8 @@ describe('installer prompt', () => { expect(prompt).toContain('add a /login client route'); expect(prompt).toContain("if (window.location.pathname === '/login') { void signIn(); }"); expect(prompt).toContain('Keep the call directly in that branch, not in a click handler or another function.'); + expect(prompt).toContain('does not need a second pathname guard'); + expect(prompt).toContain('relative default or named import'); expect(prompt).toContain('VITE_WORKOS_REDIRECT_URI for Vite'); }); diff --git a/src/lib/sign-in-route.ts b/src/lib/sign-in-route.ts index 9f33f77e..5d6871b1 100644 --- a/src/lib/sign-in-route.ts +++ b/src/lib/sign-in-route.ts @@ -18,7 +18,7 @@ export function buildSignInSection({ if (!environment.requiresApiKey) { lines.push( '', - `This is a client-only app, so add a ${signInPath} client route that calls the SDK's signIn() as soon as the AuthKit client is ready, without a click (for example, in a useEffect). AuthKit keeps password-reset and invitation details through this redirect, so signIn() needs no extra arguments. If the app has a client router, register the route there and keep it public. At startup (vanilla JS) or inside useEffect in a mounted component (React), use an explicit positive pathname guard: if (window.location.pathname === '${signInPath}') { void signIn(); }. Wait until AuthKit is ready; a readiness condition can be combined with the pathname guard using &&. Keep the call directly in that branch, not in a click handler or another function. The installer checks that this specific route starts sign-in before saving its URI; a route declaration alone is not enough. Keep the existing sign-in button. Do not add a server route.`, + `This is a client-only app, so add a ${signInPath} client route that calls the SDK's signIn() as soon as the AuthKit client is ready, without a click (for example, in a useEffect). AuthKit keeps password-reset and invitation details through this redirect, so signIn() needs no extra arguments. If the app has a client router, register the route there and keep it public. A React route such as } /> or { path: '${signInPath}', Component: Login } can use a direct useEffect(() => { if (!isLoading) void signIn(); }, [isLoading, signIn]) in Login; it does not need a second pathname guard. Define Login in the same module or use a relative default or named import from a client source file. Without a supported router binding, at startup (vanilla JS) or inside useEffect in a mounted component (React), use an explicit positive pathname guard: if (window.location.pathname === '${signInPath}') { void signIn(); }. Wait until AuthKit is ready; a readiness condition can be combined with the pathname guard using &&. Keep the call directly in that branch, not in a click handler or another function. The installer checks that this specific route starts sign-in before saving its URI; a route declaration alone is not enough. Keep the existing sign-in button. Do not add a server route.`, '', "The installer registers WORKOS_REDIRECT_URI as the app's Redirect URI. The SDK defaults to the page origin instead, so pass that value explicitly. The installer also writes it to .env.local under the bundler's env prefix (VITE_WORKOS_REDIRECT_URI for Vite, REACT_APP_WORKOS_REDIRECT_URI for Create React App), with the client ID beside it. Read that variable and set it as redirectUri on AuthKitProvider or createClient().", ); diff --git a/src/lib/validation/client-sign-in.spec.ts b/src/lib/validation/client-sign-in.spec.ts index 8e5a14b0..b211c966 100644 --- a/src/lib/validation/client-sign-in.spec.ts +++ b/src/lib/validation/client-sign-in.spec.ts @@ -1,6 +1,87 @@ import { describe, expect, it } from 'vitest'; import { hasClientSignInBehavior } from './client-sign-in.js'; +describe('mounted route component', () => { + it.each([ + 'function Login() { useEffect(() => { signIn(); }, []); return null; }', + 'function Login() { useEffect(() => { if (isLoading) return; signIn(); }, [isLoading]); return null; }', + 'function Login() { useEffect(() => { if (loading) { return; } void signIn(); }, [loading]); return null; }', + 'const Login = () => { React.useEffect(() => { if (!isLoading) signIn(); }, [isLoading]); return null; };', + 'const Login = function() { useEffect(() => { if (!loading) void authkit.signIn(); }, [loading]); return null; };', + ])('accepts supported component definitions and readiness guards: %s', (component) => { + expect(hasClientSignInBehavior(`${component} const routes = [{path: '/login', Component: Login}];`, '/login')).toBe( + true, + ); + }); + + it.each([ + 'function Login() { return ; }', + 'function Login() { function unused() { useEffect(() => signIn(), []); } return null; }', + 'function Login() { function unused() { signIn(); } useEffect(() => {}, []); return null; }', + 'function Login() { useEffect(() => { function unused() { signIn(); } }, []); return null; }', + 'function Login() { useEffect(() => { return; signIn(); }, []); return null; }', + 'function Login() { return null; useEffect(() => signIn(), []); }', + 'function Login() { useEffect(() => { if (false) signIn(); }, []); return null; }', + 'function Login() { useEffect(() => { /* signIn(); */ }, []); return null; }', + 'function Login() { return null; } function Unrelated() { useEffect(() => signIn(), []); }', + 'const Login = memo(() => { useEffect(() => signIn(), []); return null; });', + 'import Login from "./missing"; function Unrelated() { useEffect(() => signIn(), []); }', + ])('rejects a route component without a direct sign-in effect: %s', (component) => { + expect( + hasClientSignInBehavior(`${component} const routes = [{path: '/login', element: }];`, '/login'), + ).toBe(false); + }); + + it.each([ + 'function App(Login) { return } />; }', + 'function App({ Login }) { return } />; }', + 'function App() { const Login = () => null; return } />; }', + 'function App() { if (true) { var Login = () => null; } return } />; }', + 'try { fail(); } catch (Login) { const route = } />; }', + 'for (const Login of pages) { const route = } />; }', + 'const route = Login} />;', + 'const route = } />;', + 'const route = } {...overrides} />;', + "const route = {path: '/login', Component: Login, ...overrides};", + "const route = {path: '/login', element:

Login

, Component: Login};", + 'Login = () => null; const route = } />;', + '// } />', + ])('does not use an unrelated or shadowed Login binding: %s', (route) => { + expect( + hasClientSignInBehavior(`function Login() { useEffect(() => signIn(), []); return null; } ${route}`, '/login'), + ).toBe(false); + }); + + it('resolves a component declared in the route module’s local scope', () => { + expect( + hasClientSignInBehavior( + `function App() { + const Login = () => { useEffect(() => signIn(), []); return null; }; + return } />; + }`, + '/login', + ), + ).toBe(true); + }); + + it.each([ + '} />', + "const routes = [{ path: '/login', element: }];", + "const routes = [{ path: '/login', Component: Login }];", + "const routes = [{ path: '/login', component: Login }];", + ])('accepts the effect belonging to %s without a second pathname guard', (route) => { + expect( + hasClientSignInBehavior( + ` + function Login() { useEffect(() => signIn(), []); return null; } + ${route.startsWith('<') ? `function App() { return ${route}; }` : route} + `, + '/login', + ), + ).toBe(true); + }); +}); + describe('sign-in belongs to the matching pathname branch', () => { it.each([ "if (window.location.pathname === '/login') signIn();", diff --git a/src/lib/validation/client-sign-in.ts b/src/lib/validation/client-sign-in.ts index 169b018e..cd05fcdb 100644 --- a/src/lib/validation/client-sign-in.ts +++ b/src/lib/validation/client-sign-in.ts @@ -1,5 +1,6 @@ import { parse } from '@babel/parser'; -import type { Node, Statement, Expression, MemberExpression } from '@babel/types'; +import type { Node, Statement, Expression, MemberExpression, Program } from '@babel/types'; +import { posix } from 'node:path'; function propertyName(node: Node | null | undefined): string | undefined { if (node?.type === 'Identifier') return node.name; @@ -99,31 +100,325 @@ function effectStartsSignIn(node: Node, path: string): boolean { }); } +function children(node: Node): Node[] { + return Object.values(node).flatMap((value) => + Array.isArray(value) ? value.filter(isNode) : isNode(value) ? [value] : [], + ); +} + +type Bindings = Map; + +function bindPattern(pattern: Node, value: Node, bindings: Bindings): void { + if (pattern.type === 'Identifier') bindings.set(pattern.name, value); + else if (pattern.type === 'ObjectPattern') { + for (const property of pattern.properties) + bindPattern(property.type === 'RestElement' ? property.argument : property.value, value, bindings); + } else if (pattern.type === 'ArrayPattern') { + for (const element of pattern.elements) if (element) bindPattern(element, value, bindings); + } else if (pattern.type === 'AssignmentPattern') bindPattern(pattern.left, value, bindings); + else if (pattern.type === 'RestElement') bindPattern(pattern.argument, value, bindings); +} + +function scopeBindings(statements: Node[]): Bindings { + const bindings: Bindings = new Map(); + for (let statement of statements) { + if (statement.type === 'ExportNamedDeclaration' || statement.type === 'ExportDefaultDeclaration') { + if (!statement.declaration) continue; + statement = statement.declaration; + } + if ((statement.type === 'FunctionDeclaration' || statement.type === 'ClassDeclaration') && statement.id) { + bindings.set(statement.id.name, statement); + } else if (statement.type === 'VariableDeclaration') { + for (const declaration of statement.declarations) { + bindPattern( + declaration.id, + statement.kind === 'const' && declaration.id.type === 'Identifier' && declaration.init + ? declaration.init + : declaration, + bindings, + ); + } + } else if (statement.type === 'ImportDeclaration') { + for (const specifier of statement.specifiers) bindings.set(specifier.local.name, specifier); + } + } + return bindings; +} + +function isFunction( + node: Node, +): node is Extract { + return ( + node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression' + ); +} + +/** Only a direct effect in this component, never a nested helper or event callback. */ +function componentStartsSignIn(node: Node): boolean { + if (!isFunction(node) || node.body.type !== 'BlockStatement') return false; + for (const statement of node.body.body) { + if (statement.type === 'ReturnStatement' || statement.type === 'ThrowStatement') return false; + if (statement.type !== 'ExpressionStatement' || statement.expression.type !== 'CallExpression') continue; + const call = statement.expression; + const name = call.callee.type === 'MemberExpression' ? memberName(call.callee) : propertyName(call.callee); + const callback = call.arguments[0]; + if (name !== 'useEffect' || !callback || !isFunction(callback)) continue; + if (callback.body.type !== 'BlockStatement') { + if (isSignInCall(callback.body)) return true; + } else if (effectBodyStartsSignIn(callback.body.body)) return true; + } + return false; +} + +/** Readiness guards are supported, but arbitrary conditions are not route evidence. */ +function effectBodyStartsSignIn(statements: Statement[]): boolean { + for (const statement of statements) { + if (statement.type === 'ExpressionStatement' && isSignInCall(statement.expression)) return true; + if (statement.type === 'ReturnStatement') return isSignInCall(statement.argument); + if (statement.type === 'ThrowStatement') return false; + if (statement.type === 'IfStatement') { + const test = statement.test; + const body = statement.consequent.type === 'BlockStatement' ? statement.consequent.body : [statement.consequent]; + // Common SDK-ready form: if (isLoading) return; signIn(); + if ( + test.type === 'Identifier' && + ['isLoading', 'loading'].includes(test.name) && + !statement.alternate && + body.length === 1 && + body[0].type === 'ReturnStatement' && + !body[0].argument + ) + continue; + if ( + test.type !== 'UnaryExpression' || + test.operator !== '!' || + test.argument.type !== 'Identifier' || + !['isLoading', 'loading'].includes(test.argument.name) + ) + return false; + if (startsSignIn(body)) return true; + return false; + } + } + return false; +} + +function routeComponent(node: Node, path: string): string | undefined { + const values = new Map(); + if ( + node.type === 'JSXElement' && + node.openingElement.name.type === 'JSXIdentifier' && + node.openingElement.name.name === 'Route' + ) { + for (const attribute of node.openingElement.attributes) { + if (attribute.type !== 'JSXAttribute' || attribute.name.type !== 'JSXIdentifier' || !attribute.value) + return undefined; + if (values.has(attribute.name.name)) return undefined; + values.set( + attribute.name.name, + attribute.value.type === 'JSXExpressionContainer' ? attribute.value.expression : attribute.value, + ); + } + } else if (node.type === 'ObjectExpression') { + for (const property of node.properties) { + if (property.type !== 'ObjectProperty' || property.computed) return undefined; + const name = propertyName(property.key); + if (!name || values.has(name)) return undefined; + values.set(name, property.value); + } + } else return undefined; + const routePath = values.get('path'); + if (routePath?.type !== 'StringLiteral' || routePath.value !== path) return undefined; + const targets = ['element', 'Component', 'component'].filter((name) => values.has(name)); + if (targets.length !== 1) return undefined; + const target = values.get(targets[0])!; + if (targets[0] !== 'element') return target.type === 'Identifier' ? target.name : undefined; + if (target.type !== 'JSXElement' || target.openingElement.name.type !== 'JSXIdentifier') return undefined; + const name = target.openingElement.name.name; + return /^[A-Z]/.test(name) ? name : undefined; +} + +/** Conservatively decline reassigned names, including writes in nested scopes. */ +function reassignedNames(node: Node, names: Bindings = new Map()): Bindings { + if (node.type === 'AssignmentExpression') bindPattern(node.left, node, names); + if (node.type === 'UpdateExpression') bindPattern(node.argument, node, names); + for (const child of children(node)) reassignedNames(child, names); + return names; +} + +function exportedComponent(program: Program, name: string): Node | undefined { + const bindings = scopeBindings(program.body); + const reassigned = reassignedNames(program); + for (const local of reassigned.keys()) bindings.delete(local); + for (const statement of program.body) { + if (name === 'default' && statement.type === 'ExportDefaultDeclaration') { + const declaration = statement.declaration; + if (declaration.type === 'Identifier') return bindings.get(declaration.name); + if (declaration.type === 'FunctionDeclaration' && declaration.id && reassigned.has(declaration.id.name)) + return undefined; + return declaration; + } + if (statement.type !== 'ExportNamedDeclaration' || statement.source || statement.exportKind === 'type') continue; + if (statement.declaration) { + if (scopeBindings([statement.declaration]).has(name)) return bindings.get(name); + } + for (const specifier of statement.specifiers) { + if ( + specifier.type === 'ExportSpecifier' && + specifier.exportKind !== 'type' && + propertyName(specifier.exported) === name + ) + return bindings.get(specifier.local.name); + } + } + return undefined; +} + +function importedComponent( + binding: Node, + program: Program, + file: string, + modules: Map, +): Node | undefined { + if (binding.type !== 'ImportSpecifier' && binding.type !== 'ImportDefaultSpecifier') return undefined; + const declaration = program.body.find( + (node) => node.type === 'ImportDeclaration' && node.specifiers.includes(binding), + ); + if ( + declaration?.type !== 'ImportDeclaration' || + declaration.importKind === 'type' || + (binding.type === 'ImportSpecifier' && binding.importKind === 'type') + ) + return undefined; + const source = declaration.source.value; + if (!source.startsWith('./') && !source.startsWith('../')) return undefined; + const base = posix.normalize(posix.join(posix.dirname(file), source)); + const extensions = ['.tsx', '.ts', '.jsx', '.js', '.mjs']; + const candidates = [ + base, + ...extensions.map((ext) => base + ext), + ...extensions.map((ext) => `${base}/index${ext}`), + ...(/\.jsx?$/.test(base) ? [base.replace(/\.jsx?$/, '.ts'), base.replace(/\.jsx?$/, '.tsx')] : []), + ].filter((candidate) => modules.has(candidate)); + if (candidates.length !== 1) return undefined; + const programs = modules.get(candidates[0])!; + if (programs.length !== 1) return undefined; + return exportedComponent( + programs[0], + binding.type === 'ImportDefaultSpecifier' ? 'default' : propertyName(binding.imported)!, + ); +} + +/** Hoisted var bindings must also shadow outer components, even inside a nested block. */ +function hoistedVars(node: Node, bindings: Bindings): void { + for (const child of children(node)) { + if (isFunction(child)) continue; + if (child.type === 'VariableDeclaration' && child.kind === 'var') { + for (const declaration of child.declarations) bindPattern(declaration.id, declaration, bindings); + } + hoistedVars(child, bindings); + } +} + /** - * Supported automatic setup contract: a pathname-guarded SDK call at startup - * (vanilla JS) or in a mount effect (React). Router declarations alone, imported - * components and click handlers are not proof. No module/import resolution. - * A static /login.html page can instead call signIn directly at startup. - * This is source evidence only; browser-flow testing remains a separate step. + * Tie a literal route target to its lexical component binding and direct effect. + * This does not prove the router is mounted at runtime. Wrappers, re-exports, + * namespace imports and computed route targets deliberately remain unsupported. */ -export function hasClientSignInBehavior(source: string, path: string, staticPage = false): boolean { +function mountedRouteStartsSignIn( + program: Program, + path: string, + file: string, + modules: Map, +): boolean { + const reassigned = reassignedNames(program); + function visit(node: Node, scopes: Bindings[]): boolean { + // These scopes/forms are not supported; do not resolve through them by name. + if ( + node.type === 'ClassDeclaration' || + node.type === 'ClassExpression' || + node.type === 'ObjectMethod' || + node.type === 'TSModuleDeclaration' + ) + return false; + if (node.type === 'Program' || node.type === 'BlockStatement') scopes = [...scopes, scopeBindings(node.body)]; + if (isFunction(node)) { + const parameters: Bindings = new Map(); + if (node.type === 'FunctionExpression' && node.id) parameters.set(node.id.name, node); + for (const parameter of node.params) bindPattern(parameter, parameter, parameters); + hoistedVars(node.body, parameters); + scopes = [...scopes, parameters]; + } + if (node.type === 'CatchClause' && node.param) { + const bindings: Bindings = new Map(); + bindPattern(node.param, node.param, bindings); + scopes = [...scopes, bindings]; + } + if (node.type === 'ForStatement' && node.init?.type === 'VariableDeclaration') + scopes = [...scopes, scopeBindings([node.init])]; + if ((node.type === 'ForInStatement' || node.type === 'ForOfStatement') && node.left.type === 'VariableDeclaration') + scopes = [...scopes, scopeBindings([node.left])]; + if (node.type === 'SwitchStatement') + scopes = [...scopes, scopeBindings(node.cases.flatMap((branch) => branch.consequent))]; + const name = routeComponent(node, path); + if (name && !reassigned.has(name)) { + const binding = [...scopes] + .reverse() + .find((scope) => scope.has(name)) + ?.get(name); + if (binding && componentStartsSignIn(importedComponent(binding, program, file, modules) ?? binding)) return true; + } + return children(node).some((child) => visit(child, scopes)); + } + return visit(program, []); +} + +export interface ClientSource { + file: string; + content: string; +} + +function parsePrograms(source: string): Program[] { const scripts = /]*)>([\s\S]*?)<\/script\s*>/gi)] .filter(([, attributes]) => !/\bsrc\s*=/i.test(attributes)) .map(([, , body]) => body) : [source]; - return scripts.some((script) => { + return scripts.flatMap((script) => { try { - const program = parse(script, { - sourceType: 'unambiguous', - plugins: ['typescript', 'jsx'], - allowReturnOutsideFunction: true, - }).program; - if (staticPage) return startsSignIn(program.body); - return guardedSignIn(program.body, path) || effectStartsSignIn(program, path); + return [ + parse(script, { + sourceType: 'unambiguous', + plugins: ['typescript', 'jsx'], + allowReturnOutsideFunction: true, + }).program, + ]; } catch { // Unparseable or unsupported source must not authorize a dashboard write. - return false; + return []; } }); } + +/** Source evidence only; browser-flow testing remains a separate step. */ +export function hasClientSignInBehavior(source: string, path: string, staticPage = false): boolean { + return hasClientSignInBehaviorInSources([{ file: '', content: source }], path, staticPage); +} + +/** Imports resolve only against these already bounded records, never additional disk reads. */ +export function hasClientSignInBehaviorInSources(sources: ClientSource[], path: string, staticPage = false): boolean { + const modules = new Map(sources.map(({ file, content }) => [file, parsePrograms(content)])); + for (const [file, programs] of modules) { + for (const program of programs) { + if ( + staticPage + ? startsSignIn(program.body) + : guardedSignIn(program.body, path) || + effectStartsSignIn(program, path) || + mountedRouteStartsSignIn(program, path, file, modules) + ) + return true; + } + } + return false; +} diff --git a/src/lib/validation/client-source.spec.ts b/src/lib/validation/client-source.spec.ts index 7f0dd5b6..e52b481b 100644 --- a/src/lib/validation/client-source.spec.ts +++ b/src/lib/validation/client-source.spec.ts @@ -96,6 +96,96 @@ it('does not scan other workspaces as this app', async () => { expect(await hasClientSignInRoute(project, '/login')).toBe(false); }); +it.each([ + ['import Login from "./Login";', 'export default function Login()'], + ['import { Login } from "./Login";', 'export function Login()'], + ['import { Entry as Login } from "./Login";', 'export function Entry()'], + ['import Login from "./Login.tsx";', 'export default () =>'], + ['import Login from "./Login.js";', 'export default function Login()'], + ['import Login from "./Login.jsx";', 'export default function Login()'], + ['import { Login } from "./Login";', 'export const Login = () =>'], + ['import Login from "./Login";', 'const Entry = function()'], +])('resolves a split-file mounted login component: %s', async (importLine, declaration) => { + await file( + 'src/App.tsx', + `${importLine} export default function App() { + return } />; + }`, + ); + await file( + 'src/Login.tsx', + `${declaration} { + const { signIn, isLoading } = useAuth(); + useEffect(() => { if (!isLoading) void signIn(); }, [isLoading, signIn]); + return Signing in; + } ${declaration.startsWith('const') ? '; export { Entry as default };' : ''}`, + ); + expect(await hasClientSignInRoute(project, '/login')).toBe(true); + const result = await validateInstallation('react', project, { runBuild: false }); + expect(result.issues.some((issue) => issue.message.includes('/login route'))).toBe(false); +}); + +it.each([ + ['import Login from "./Missing";', 'export default function Login() { useEffect(() => signIn(), []); return null; }'], + [ + 'import Login from "@pages/Login";', + 'export default function Login() { useEffect(() => signIn(), []); return null; }', + ], + ['import Login from "./Login";', 'export function Login() { useEffect(() => signIn(), []); return null; }'], + [ + 'import { Missing as Login } from "./Login";', + 'export function Login() { useEffect(() => signIn(), []); return null; }', + ], + ['import { Login } from "./Login";', 'function Login() { useEffect(() => signIn(), []); return null; }'], + [ + 'import Login from "./Login";', + 'export default function Page() { return

Login

; } function Login() { useEffect(() => signIn(), []); return null; }', + ], + ['import Login from "./Login";', 'export { default } from "./Unrelated";'], + ['import type { Login } from "./Login";', 'export function Login() { useEffect(() => signIn(), []); return null; }'], +])('fails closed for unresolved or wrong component exports: %s %s', async (importLine, component) => { + await file( + 'src/App.tsx', + `${importLine} export default function App() { return } />; }`, + ); + await file('src/Login.tsx', component); + await file('src/Unrelated.tsx', 'export default function Login() { useEffect(() => signIn(), []); return null; }'); + expect(await hasClientSignInRoute(project, '/login')).toBe(false); + const result = await validateInstallation('react', project, { runBuild: false }); + expect(result.issues.some((issue) => issue.message.includes('/login route'))).toBe(true); +}); + +it.each(['element: ', 'Component: Login', 'component: Login'])( + 'resolves imported object route %s via a directory index', + async (target) => { + await file( + 'src/App.tsx', + `import { Entry as Login } from './pages'; const routes = [{path: '/login', ${target}}];`, + ); + await file( + 'src/pages/index.tsx', + 'const Page = () => { useEffect(() => signIn(), []); return null; }; export { Page as Entry };', + ); + expect(await hasClientSignInRoute(project, '/login')).toBe(true); + }, +); + +it('declines ambiguous relative module resolution', async () => { + await file('src/App.tsx', 'import Login from "./Login"; const routes = [{path: "/login", Component: Login}];'); + await file('src/Login.tsx', 'export default function Login() { useEffect(() => signIn(), []); return null; }'); + await file('src/Login/index.tsx', 'export default function Login() { return null; }'); + expect(await hasClientSignInRoute(project, '/login')).toBe(false); +}); + +it('does not expand the bounded scan to resolve imports', async () => { + await file( + 'src/App.tsx', + 'import Login from "../packages/Login"; const routes = [{path: "/login", Component: Login}];', + ); + await file('packages/Login.tsx', 'export default function Login() { useEffect(() => signIn(), []); return null; }'); + expect(await hasClientSignInRoute(project, '/login')).toBe(false); +}); + describe('bounded scan', () => { async function incomplete() { expect(await hasClientSignInRoute(project, '/login')).toBe(false); diff --git a/src/lib/validation/validator.ts b/src/lib/validation/validator.ts index 2273b5a7..274ce61e 100644 --- a/src/lib/validation/validator.ts +++ b/src/lib/validation/validator.ts @@ -4,7 +4,7 @@ import { join } from 'path'; import fg from 'fast-glob'; import type { ValidationResult, ValidationRules, ValidationIssue } from './types.js'; import { runBuildValidation } from './build-validator.js'; -import { hasClientSignInBehavior } from './client-sign-in.js'; +import { hasClientSignInBehavior, hasClientSignInBehaviorInSources, type ClientSource } from './client-sign-in.js'; import { detectPort, getClientEnvPrefix, getSignInPath } from '../port-detection.js'; import { nextjsRoutePath, findNextjsSignInPage } from '../../integrations/nextjs/utils.js'; import nextjsRules from './rules/nextjs.json' with { type: 'json' }; @@ -301,9 +301,9 @@ async function validateClientOnlyApp(framework: string, projectDir: string, issu type: 'file', severity: 'error', message: `No ${signInPath} route starts sign-in`, - hint: `At startup or in a mount effect, call signIn() inside an if (window.location.pathname === '${signInPath}') branch once AuthKit is ready. A route declaration or unrelated sign-in button is not enough.`, + hint: `Call signIn() once AuthKit is ready in the mounted ${signInPath} route component's useEffect, or at startup/in an effect inside an if (window.location.pathname === '${signInPath}') branch. A route declaration or unrelated sign-in button is not enough.`, }); - if (!sources.some((content) => content.includes('redirectUri'))) + if (!sources.some(({ content }) => content.includes('redirectUri'))) issues.push({ type: 'pattern', severity: 'error', @@ -313,7 +313,7 @@ async function validateClientOnlyApp(framework: string, projectDir: string, issu if (!prefix) return; const expected = BROWSER_REDIRECT_ENV[prefix]; const reads = new Set( - sources.flatMap((content) => [...content.matchAll(REDIRECT_ENV_REFERENCE)].map(([read]) => read)), + sources.flatMap(({ content }) => [...content.matchAll(REDIRECT_ENV_REFERENCE)].map(([read]) => read)), ); reads.delete(expected); for (const read of reads) @@ -340,8 +340,8 @@ export async function hasClientSignInRoute(projectDir: string, signInPath: strin return complete && servesSignInRoute(projectDir, sources, signInPath); } -async function servesSignInRoute(projectDir: string, sources: string[], signInPath: string): Promise { - if (sources.some((content) => hasClientSignInBehavior(content, signInPath))) return true; +async function servesSignInRoute(projectDir: string, sources: ClientSource[], signInPath: string): Promise { + if (hasClientSignInBehaviorInSources(sources, signInPath)) return true; // A static page at the path, e.g. login/index.html, that starts sign-in. const segment = signInPath.replace(/^\/|\/$/g, ''); const pages = await fg( @@ -381,7 +381,8 @@ async function readBoundedSource(path: string, limit: number): Promise, -): Promise<{ sources: string[]; complete: boolean }> { +): Promise<{ sources: ClientSource[]; complete: boolean }> { const extension = '*.{ts,tsx,js,jsx,mjs,html,htm}'; const files = fg.stream( prefix === 'REACT_APP_' ? [`src/**/${extension}`] : [extension, `{src,app,client}/**/${extension}`], @@ -406,7 +407,7 @@ async function readClientSource( ], }, ); - const sources: string[] = []; + const sources: ClientSource[] = []; let remaining = MAX_SCAN_BYTES; try { for await (const file of files) { @@ -414,7 +415,7 @@ async function readClientSource( const content = await readBoundedSource(join(projectDir, String(file)), Math.min(MAX_SOURCE_BYTES, remaining)); if (content === undefined) return { sources, complete: false }; remaining -= Buffer.byteLength(content); - sources.push(content); + sources.push({ file: String(file).replaceAll('\\', '/'), content }); } } catch { return { sources, complete: false }; From 4434c073d32e0327c2f1666f9e33faed8a63f93d Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 22:26:00 -0500 Subject: [PATCH 28/34] fix(install): configure all application URLs through one target Defer non-Next.js URL writes until post-agent setup chooses the client-ID-matched dashboard application or the API-key-only fallback. Never write early through API key A then later through dashboard environment B. Include CORS in that choice: preserve existing dashboard origins with dry-run/recheck/readback, or add through REST only on the API-key path. An unregistered callback cannot be reported as successful setup. Cover mismatched credentials, late sessions, production refusal, CORS preservation and incomplete results. --- README.md | 10 +- src/lib/authkit-application-setup.spec.ts | 157 ++++++++++++++++++++++ src/lib/authkit-application-setup.ts | 59 +++++++- src/lib/completion-data.spec.ts | 20 +++ src/lib/completion-data.ts | 3 + src/lib/run-with-core.setup.spec.ts | 125 ++++++++--------- src/lib/run-with-core.ts | 76 ++++------- src/lib/workos-management.ts | 5 +- 8 files changed, 335 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index 3c6ae363..e926c6ec 100644 --- a/README.md +++ b/README.md @@ -713,12 +713,12 @@ OAuth credentials are stored in the system keychain (with `~/.workos/credentials 1. **Detects** your framework and project structure 2. **Resolves credentials** — uses existing config, or auto-provisions an unclaimed environment if none found -3. **Auto-configures** WorkOS dashboard (redirect URI, CORS, homepage URL) +3. **Prepares** local environment variables securely 4. **Fetches** latest SDK documentation from workos.com -5. **Uses AI** (Claude) to generate integration code -6. **Installs** SDK with detected package manager -7. **Creates** auth routes, middleware, and UI -8. **Configures** environment variables securely +5. **Uses AI** (Claude) to install the SDK and create auth routes, middleware, and UI +6. **Checks** the generated integration +7. **Configures sandbox URLs** after the agent, using one WorkOS target: the client-ID-matched dashboard application, or the API-key-only fallback. Existing settings are preserved unless an explicit override is allowed; unsupported or unverified settings are reported for manual setup. +8. **Reports** what was configured and what still needs attention. Test sign-in, sign-out, and invitation/password-reset flows in the browser before treating the integration as complete. ## Telemetry diff --git a/src/lib/authkit-application-setup.spec.ts b/src/lib/authkit-application-setup.spec.ts index 2702ee7e..448fc321 100644 --- a/src/lib/authkit-application-setup.spec.ts +++ b/src/lib/authkit-application-setup.spec.ts @@ -28,6 +28,8 @@ import { configureAuthkitApplication, readNextjsApplicationSetup, } from './authkit-application-setup.js'; +import { configureInstallEnvironment, configureOtherApplicationUrls } from './run-with-core.js'; +import { createInstallerEventEmitter } from './events.js'; import { applicationSetupNextSteps } from './completion-data.js'; const setup = { @@ -44,6 +46,7 @@ let application: { logoutUris: { id?: string; uri: string; isDefault: boolean }[]; initiateLoginUri: string | null; appHomepageUrl?: string; + webOrigins: { origin: string }[]; }; const writes = () => vi @@ -68,6 +71,7 @@ beforeEach(() => { ]); application = { id: 'app_1', + webOrigins: [{ origin: 'https://custom.example' }], clientId: setup.clientId, redirectUris: [{ uri: setup.redirectUri, isDefault: true }], logoutUris: [{ id: 'uri_old', uri: 'https://old.example/', isDefault: false }], @@ -76,6 +80,12 @@ beforeEach(() => { }; vi.mocked(dashboardGraphqlRequest).mockImplementation(async (name, options) => { if (name === 'defaultAuthkitApplication') return { defaultUserlandApplication: structuredClone(application) }; + if (name === 'setAuthkitApplicationWebOrigins') { + const input = options.variables!.input as { applicationId: string; origins: string[]; dryRun: boolean }; + expect(input.applicationId).toBe('app_1'); + if (!input.dryRun) application.webOrigins = input.origins.map((origin) => ({ origin })); + return { setUserlandApplicationWebOrigins: { __typename: 'WebOriginsSet' } }; + } if (name === 'setAuthkitApplicationLogoutUris') { const input = options.variables!.input as { applicationId: string; @@ -114,6 +124,153 @@ beforeEach(() => { afterEach(() => vi.unstubAllGlobals()); describe('native application URL setup', () => { + it.each([false, true])( + 'uses only client B dashboard even when session appears after preparation (%s)', + async (lateSession) => { + const directory = await mkdtemp(join(tmpdir(), 'single-target-')); + const request = vi.fn(async () => Response.json({ url: null })); + vi.stubGlobal('fetch', request); + const context = { + options: { installDir: directory, debug: false, forceInstall: false, local: false, ci: true, skipAuth: true }, + integration: 'sveltekit', + credentials: { apiKey: 'sk_test_environment_a', clientId: setup.clientId }, + emitter: createInstallerEventEmitter(), + }; + try { + if (lateSession) vi.mocked(refreshIfExpired).mockResolvedValue(null); + await configureInstallEnvironment(context); + expect(request).not.toHaveBeenCalled(); + vi.mocked(refreshIfExpired).mockResolvedValue({ accessToken: 'session_b', refreshed: false }); + const result = await configureOtherApplicationUrls(context, setup.clientId, context.credentials.apiKey); + expect(result?.corsRegistered).toBe(true); + expect(writes().map(([name]) => name)).toContain('setAuthkitApplicationWebOrigins'); + expect(writes().map(([name]) => name)).toContain('setRedirectUris'); + expect(request).not.toHaveBeenCalled(); + for (const [, options] of vi.mocked(dashboardGraphqlRequest).mock.calls) { + expect(options.environmentId).toBe('env_app'); + expect(options.token).toBe('session_b'); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, + ); + it('appends dashboard CORS without replacing custom origins and is idempotent', async () => { + const withCors = { ...setup, corsOrigin: 'http://localhost:4000' }; + expect((await configureAuthkitApplication(withCors, setup.clientId)).corsRegistered).toBe(true); + expect(application.webOrigins).toEqual([{ origin: 'https://custom.example' }, { origin: withCors.corsOrigin }]); + const corsCalls = () => + vi.mocked(dashboardGraphqlRequest).mock.calls.filter(([name]) => name === 'setAuthkitApplicationWebOrigins'); + expect(corsCalls().map(([, options]) => options.variables!.input)).toEqual([ + { applicationId: 'app_1', origins: ['https://custom.example', withCors.corsOrigin], dryRun: true }, + { applicationId: 'app_1', origins: ['https://custom.example', withCors.corsOrigin], dryRun: false }, + ]); + expect((await configureAuthkitApplication(withCors, setup.clientId)).corsRegistered).toBe(true); + expect(corsCalls()).toHaveLength(2); + }); + + it.each(['validation', 'race', 'readback', 'missing'])( + 'refuses unsafe dashboard CORS (%s) without API fallback', + async (failure) => { + const original = vi.mocked(dashboardGraphqlRequest).getMockImplementation()!; + if (failure === 'missing') delete (application as Partial).webOrigins; + vi.mocked(dashboardGraphqlRequest).mockImplementation(async (name, options) => { + if (name === 'setAuthkitApplicationWebOrigins') { + const input = options.variables!.input as { dryRun: boolean }; + if (failure === 'validation') + return { setUserlandApplicationWebOrigins: { __typename: 'MalformedWebOrigin' } }; + if (failure === 'race' && input.dryRun) application.webOrigins.push({ origin: 'https://concurrent.example' }); + if (failure === 'readback' && !input.dryRun) + return { setUserlandApplicationWebOrigins: { __typename: 'WebOriginsSet' } }; + } + return original(name, options); + }); + const result = await configureAuthkitApplication( + { ...setup, corsOrigin: 'http://localhost:4000' }, + setup.clientId, + 'sk_test_environment_a', + ); + expect(result.callbackRegistered).toBe(true); + expect(result.corsRegistered).toBe(false); + expect(result.verified).toBe(false); + expect(fetch).not.toHaveBeenCalled(); + if (failure !== 'readback') expect(writes()).toHaveLength(0); + }, + ); + + it('registers API-only CORS before leaving an unreadable existing homepage unchanged', async () => { + vi.mocked(refreshIfExpired).mockResolvedValue(null); + const request = vi.fn(async () => Response.json({})); + vi.stubGlobal('fetch', request); + const result = await configureAuthkitApplication( + { ...setup, corsOrigin: 'http://localhost:4000' }, + setup.clientId, + 'sk_test_environment_a', + ); + expect(result.corsRegistered).toBe(true); + expect(request.mock.calls.map(([url]) => url)).toEqual([ + 'https://api.workos.com/user_management/redirect_uris', + 'https://api.workos.com/user_management/cors_origins', + ]); + expect(dashboardGraphqlRequest).not.toHaveBeenCalled(); + }); + + it.each(['explicit', 'unclaimed'])('preserves API-only fresh-install CORS and homepage setup (%s)', async (kind) => { + vi.mocked(refreshIfExpired).mockResolvedValue(null); + if (kind === 'unclaimed') + vi.mocked(getActiveEnvironment).mockReturnValue({ + name: 'Unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_environment_a', + clientId: setup.clientId, + claimToken: 'claim', + }); + const request = vi.fn(async (_url: string, init: RequestInit) => + init.method === 'GET' ? new Response(null, { status: 404 }) : Response.json({}), + ); + vi.stubGlobal('fetch', request); + const result = await configureAuthkitApplication( + { + ...setup, + corsOrigin: 'http://localhost:4000', + ...(kind === 'explicit' ? { homepageUrl: 'http://localhost:4000' } : {}), + }, + setup.clientId, + 'sk_test_environment_a', + ); + expect(result).toMatchObject({ callbackRegistered: true, corsRegistered: true, verified: false }); + expect(request).toHaveBeenCalledWith( + 'https://api.workos.com/user_management/app_homepage_url', + expect.objectContaining({ method: 'PUT', body: JSON.stringify({ url: 'http://localhost:4000' }) }), + ); + expect(dashboardGraphqlRequest).not.toHaveBeenCalled(); + }); + + it.each([undefined, 'sk_live_production'])( + 'does not write API-only CORS without a sandbox key (%s)', + async (apiKey) => { + vi.mocked(refreshIfExpired).mockResolvedValue(null); + await expect( + configureAuthkitApplication({ ...setup, corsOrigin: 'http://localhost:4000' }, setup.clientId, apiKey), + ).rejects.toThrow(/Callback/); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + it('keeps production dashboard CORS read-only', async () => { + vi.mocked(fetchTeamEnvironments).mockResolvedValue([ + { id: 'env_app', name: 'Production', sandbox: false, clientId: setup.clientId }, + ]); + const result = await configureAuthkitApplication( + { ...setup, corsOrigin: 'http://localhost:4000' }, + setup.clientId, + 'sk_test_environment_a', + ); + expect(result.corsRegistered).toBe(false); + expect(writes()).toHaveLength(0); + expect(fetch).not.toHaveBeenCalled(); + }); + it('registers the callback with an API key without a dashboard session', async () => { vi.mocked(refreshIfExpired).mockResolvedValue(null); const request = vi.fn(async () => Response.json({ url: null })); diff --git a/src/lib/authkit-application-setup.ts b/src/lib/authkit-application-setup.ts index 34235df0..923d60e0 100644 --- a/src/lib/authkit-application-setup.ts +++ b/src/lib/authkit-application-setup.ts @@ -6,7 +6,7 @@ import { fetchTeamEnvironments } from './environment-target.js'; import { dashboardGraphqlRequest } from './dashboard-graphql.js'; import { getOperation, resolveExecutableDocument } from '../catalog/operation.js'; import { InstallDeclinedError } from './installer-errors.js'; -import { isUnclaimedEnvironmentKey, setHomepageUrl } from './workos-management.js'; +import { createCorsOrigin, isUnclaimedEnvironmentKey, setHomepageUrl } from './workos-management.js'; import { getSignInPath } from './port-detection.js'; export interface AuthkitApplicationSetup { @@ -17,6 +17,9 @@ export interface AuthkitApplicationSetup { initiateLoginUri?: string; initiateLoginReason?: string; homepageUrl?: string; + /** Browser origin to add without replacing existing allowed origins. */ + corsOrigin?: string; + corsRegistered?: boolean; verified: boolean; /** The callback was registered; this alone does not verify the other URLs or browser flows. */ callbackRegistered?: boolean; @@ -36,6 +39,7 @@ interface Application { logoutUris: Uri[]; initiateLoginUri: string | null; appHomepageUrl?: string | null; + webOrigins?: { origin: string }[]; } /** Use the app's saved callback, not the active profile or a guessed localhost port. */ @@ -105,6 +109,8 @@ export async function configureAuthkitApplication( 'No client sign-in route is available. Add a sign-in route and configure the Initiate login URI in the dashboard.', }; } + // Never trust incoming registration flags; they belong to an earlier attempt. + setup = { ...setup, ...(setup.corsOrigin !== undefined ? { corsRegistered: false } : {}) }; let callbackRegistered = false; const pending = (reason: string): AuthkitApplicationSetup => { if (!callbackRegistered) { @@ -139,6 +145,16 @@ export async function configureAuthkitApplication( return pending('Could not register the callback URL. Check the API key and connection, then retry setup.'); } callbackRegistered = true; + if (setup.corsOrigin !== undefined) { + try { + await createCorsOrigin(apiKey, setup.corsOrigin); + setup = { ...setup, corsRegistered: true }; + } catch { + return pending( + 'Callback registered using the API key, but CORS origin setup failed. Check the application URLs in the dashboard.', + ); + } + } // REST cannot read the current homepage. Only an explicit override or the // stored active unclaimed key authorizes replacing this single-valued setting. if (setup.homepageUrl === undefined && !isUnclaimedEnvironmentKey(apiKey)) { @@ -247,6 +263,39 @@ export async function configureAuthkitApplication( } callbackRegistered = true; + if (setup.corsOrigin !== undefined) { + const originsOf = (app: Application): string[] => { + if (!Array.isArray(app.webOrigins) || !app.webOrigins.every((entry) => typeof entry.origin === 'string')) + throw new Error('Application web origins unavailable'); + return app.webOrigins.map((entry) => entry.origin); + }; + const existing = originsOf(original); + if (!existing.includes(setup.corsOrigin)) { + // This replaces the list: merge, validate, recheck, then read back. + // Like redirects, the API offers no atomic compare-and-set precondition. + const input = { applicationId: original.id, origins: [...existing, setup.corsOrigin] }; + const validated = await request<{ setUserlandApplicationWebOrigins: { __typename: string } }>( + 'setAuthkitApplicationWebOrigins', + { input: { ...input, dryRun: true } }, + ); + if (validated.setUserlandApplicationWebOrigins.__typename !== 'WebOriginsSet') + return pending('CORS origin validation failed. Existing origins were left unchanged.'); + if (JSON.stringify(await readApplication()) !== JSON.stringify(original)) + return pending('Application settings changed during setup. Recheck them before applying changes.'); + const written = await request<{ setUserlandApplicationWebOrigins: { __typename: string } }>( + 'setAuthkitApplicationWebOrigins', + { input: { ...input, dryRun: false } }, + ); + if (written.setUserlandApplicationWebOrigins.__typename !== 'WebOriginsSet') + return pending('Could not save the CORS origin. Check the dashboard before continuing.'); + const saved = await readApplication(); + if (saved.id !== original.id || !input.origins.every((origin) => originsOf(saved).includes(origin))) + return pending('CORS read-back did not match the required settings.'); + original = saved; + } + setup = { ...setup, corsRegistered: true }; + } + const reasons: string[] = setup.initiateLoginUri === undefined ? [setup.initiateLoginReason!] : []; const defaults = original.logoutUris.filter((uri) => uri.isDefault); const signOutConflict = defaults.length > 1 || defaults.some((uri) => !isSignOutDestination(uri.uri)); @@ -321,6 +370,14 @@ export async function configureAuthkitApplication( const saved = await readApplication(); callbackRegistered = saved.id === original.id && saved.redirectUris.some((uri) => uri.uri === setup.redirectUri); if (!callbackRegistered) return pending('Callback read-back did not match the required settings.'); + if ( + setup.corsOrigin !== undefined && + (!saved.webOrigins?.some((entry) => entry.origin === setup.corsOrigin) || + !original.webOrigins?.every((old) => saved.webOrigins?.some((entry) => entry.origin === old.origin))) + ) { + setup = { ...setup, corsRegistered: false }; + return pending('CORS read-back did not match the required settings.'); + } if (reasons.length) return pending(reasons.join(' ')); if ( !original.redirectUris.every((old) => diff --git a/src/lib/completion-data.spec.ts b/src/lib/completion-data.spec.ts index 42f5c20c..deaeed2d 100644 --- a/src/lib/completion-data.spec.ts +++ b/src/lib/completion-data.spec.ts @@ -70,6 +70,26 @@ describe('buildCompletionData', () => { ); }); + it.each([true, false])( + 'retains optional camelCase CORS status in public completion data (%s)', + async (corsRegistered) => { + const applicationSetup = { + clientId: 'client_app', + redirectUri: 'http://localhost:5173/callback', + signOutUri: 'http://localhost:5173/', + corsOrigin: 'http://localhost:5173', + corsRegistered, + callbackRegistered: true, + verified: false, + }; + const data = await buildCompletionData({ integration: 'react', installDir }, { ...baseDeps, applicationSetup }); + expect(JSON.parse(JSON.stringify(data)).applicationSetup).toEqual(applicationSetup); + expect(data.nextSteps).toContain( + `CORS origin: http://localhost:5173 (${corsRegistered ? 'registered' : 'not registered or verified'})`, + ); + }, + ); + it('respects a Vite server.port override for react', async () => { writePackageJson({ scripts: { dev: 'vite' }, dependencies: { react: '18.0.0', vite: '5.0.0' } }); writeFile('vite.config.ts', 'export default { server: { port: 8080 } };'); diff --git a/src/lib/completion-data.ts b/src/lib/completion-data.ts index 9480c8c4..760e95ce 100644 --- a/src/lib/completion-data.ts +++ b/src/lib/completion-data.ts @@ -9,6 +9,9 @@ export function applicationSetupNextSteps(setup: AuthkitApplicationSetup): strin ? 'Application URLs were read back and verified; browser flows are not yet tested.' : `Application setup is incomplete: ${setup.reason ?? 'Settings have not been verified.'}`, `Redirect URI: ${setup.redirectUri} (${setup.callbackRegistered || setup.verified ? 'registered' : 'not registered or verified'})`, + ...(setup.corsOrigin !== undefined + ? [`CORS origin: ${setup.corsOrigin} (${setup.corsRegistered ? 'registered' : 'not registered or verified'})`] + : []), `Sign-out URI: ${setup.signOutUri}`, setup.initiateLoginUri !== undefined ? `Initiate login URI: ${setup.initiateLoginUri} (starts sign-in; never use the callback URI)` diff --git a/src/lib/run-with-core.setup.spec.ts b/src/lib/run-with-core.setup.spec.ts index bdcf7a4b..0553425e 100644 --- a/src/lib/run-with-core.setup.spec.ts +++ b/src/lib/run-with-core.setup.spec.ts @@ -11,7 +11,6 @@ import { } from './run-with-core.js'; import { configureAuthkitApplication } from './authkit-application-setup.js'; import { InstallDeclinedError } from './installer-errors.js'; -import { SANDBOX_ONLY_REASON } from './workos-management.js'; vi.mock('./authkit-application-setup.js', async (importOriginal) => ({ ...(await importOriginal()), @@ -129,7 +128,7 @@ describe('dashboard checklist reporting', () => { expect(events).toEqual(['config:step env-vars started', 'config:step env-vars done']); }); - it('reports the redirect URI and CORS origin as they land for React Router', async () => { + it('defers React Router URL events until after the agent', async () => { const { emitter, events } = record(); await configureInstallEnvironment({ options, @@ -137,19 +136,11 @@ describe('dashboard checklist reporting', () => { credentials: { apiKey: 'sk_test_a', clientId: 'client_a' }, emitter, }); - expect(events).toEqual([ - 'config:step env-vars started', - 'config:step redirect-uri started', - 'config:step cors-origin started', - 'config:step redirect-uri done', - 'config:step cors-origin done', - 'config:step env-vars done', - ]); + expect(events).toEqual(['config:step env-vars started', 'config:step env-vars done']); + expect(fetchSpy).not.toHaveBeenCalled(); }); - const registeredPaths = () => fetchSpy.mock.calls.map(([url]) => new URL(String(url)).pathname); - - it('registers the redirect URI and CORS origin for every server-side SDK', async () => { + it('defers URL writes for other server-side SDKs', async () => { const { emitter, events } = record(); await configureInstallEnvironment({ options, @@ -157,34 +148,24 @@ describe('dashboard checklist reporting', () => { credentials: { apiKey: 'sk_test_a', clientId: 'client_a' }, emitter, }); - expect(registeredPaths()).toEqual( - expect.arrayContaining(['/user_management/redirect_uris', '/user_management/cors_origins']), - ); - expect(events).toContain('config:step redirect-uri done'); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(events.some((event) => event.includes('redirect-uri'))).toBe(false); expect(events).toContain('config:step env-vars done'); }); - it.each(['sveltekit', 'go'])( - 'writes no localhost URLs to a production environment for %s, and says why', - async (integration) => { - const { emitter, events } = record(); - await configureInstallEnvironment({ - options, - integration, - credentials: { apiKey: 'sk_live_a', clientId: 'client_a' }, - emitter, - }); - expect(fetchSpy).not.toHaveBeenCalled(); - expect(events).toEqual( - expect.arrayContaining([ - `config:step redirect-uri skipped (${SANDBOX_ONLY_REASON})`, - `config:step cors-origin skipped (${SANDBOX_ONLY_REASON})`, - ]), - ); - }, - ); + it.each(['sveltekit', 'go'])('defers production URL handling for %s', async (integration) => { + const { emitter, events } = record(); + await configureInstallEnvironment({ + options, + integration, + credentials: { apiKey: 'sk_live_a', clientId: 'client_a' }, + emitter, + }); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(events.every((event) => event.includes('env-vars'))).toBe(true); + }); - it('registers the URLs for a non-JavaScript SDK without writing .env.local', async () => { + it('defers non-JavaScript URLs without writing .env.local', async () => { const { emitter, events } = record(); await configureInstallEnvironment({ options, @@ -192,9 +173,8 @@ describe('dashboard checklist reporting', () => { credentials: { apiKey: 'sk_test_a', clientId: 'client_a' }, emitter, }); - const redirect = fetchSpy.mock.calls.find(([url]) => String(url).endsWith('/user_management/redirect_uris')); - expect(JSON.parse(String(redirect?.[1]?.body))).toEqual({ uri: 'http://localhost:3000/auth/callback' }); - expect(events).toContain('config:step redirect-uri done'); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(events).toEqual([]); expect(events.some((event) => event.includes('env-vars'))).toBe(false); await expect(readFile(join(directory, '.env.local'))).rejects.toMatchObject({ code: 'ENOENT' }); }); @@ -223,21 +203,18 @@ describe('dashboard checklist reporting', () => { }); it.each(['react', 'vanilla-js'] as const)( - 'registers the Vite callback and CORS origin for the client-only %s SDK', + 'defers callback and CORS writes for the client-only %s SDK', async (integration) => { await configureInstallEnvironment({ options, integration, credentials: { apiKey: 'sk_test_a', clientId: 'client_a' }, }); - const bodyFor = (path: string) => - JSON.parse(String(fetchSpy.mock.calls.find(([url]) => String(url).endsWith(path))?.[1]?.body)); - expect(bodyFor('/user_management/redirect_uris')).toEqual({ uri: 'http://localhost:5173/callback' }); - expect(bodyFor('/user_management/cors_origins')).toEqual({ origin: 'http://localhost:5173' }); + expect(fetchSpy).not.toHaveBeenCalled(); }, ); - it('says why a client-only SDK without an API key has no URLs registered', async () => { + it('does not prematurely skip client-only URLs without an API key', async () => { const { emitter, events } = record(); await configureInstallEnvironment({ options, @@ -245,11 +222,11 @@ describe('dashboard checklist reporting', () => { credentials: { clientId: 'client_a' }, emitter, }); - expect(events).toContain('config:step redirect-uri skipped (No API key was available for this install.)'); + expect(events).toEqual(['config:step env-vars started', 'config:step env-vars done']); expect(fetchSpy).not.toHaveBeenCalled(); }); - it('says why the redirect URI and CORS origin were not set without an API key', async () => { + it('does not prematurely skip server URLs without an API key', async () => { const { emitter, events } = record(); await configureInstallEnvironment({ options, @@ -257,8 +234,7 @@ describe('dashboard checklist reporting', () => { credentials: { clientId: 'client_a' }, emitter, }); - expect(events).toContain('config:step redirect-uri skipped (No API key was available for this install.)'); - expect(events).toContain('config:step cors-origin skipped (No API key was available for this install.)'); + expect(events).toEqual(['config:step env-vars started', 'config:step env-vars done']); expect(fetchSpy).not.toHaveBeenCalled(); }); @@ -274,6 +250,28 @@ describe('dashboard checklist reporting', () => { ]); }); + it.each([true, false])('reports CORS independently of pending dashboard settings (%s)', async (corsRegistered) => { + const { emitter, events } = record(); + await reportAppUrlSetup( + emitter, + async () => ({ + ...setup, + corsOrigin: 'http://localhost:5173', + corsRegistered, + callbackRegistered: true, + verified: false, + reason: 'Manual setup needed.', + }), + { includeCors: true }, + ); + expect(events).toContain('app-urls:step redirect-uri done'); + expect(events).toContain('app-urls:step cors-origin started'); + expect(events).toContain( + corsRegistered ? 'app-urls:step cors-origin done' : 'app-urls:step cors-origin skipped (Manual setup needed.)', + ); + expect(events).toContain('app-urls:step sign-out-uri skipped (Manual setup needed.)'); + }); + it('flags the unverified URLs with the setup reason', async () => { const { emitter, events } = record(); const result = await reportAppUrlSetup(emitter, async () => ({ @@ -347,6 +345,7 @@ describe('application URLs for SDKs other than Next.js', () => { clientId: 'client_a', redirectUri: 'http://localhost:8080/auth/callback', signOutUri: 'http://localhost:8080/', + corsOrigin: 'http://localhost:8080', initiateLoginUri: 'http://localhost:8080/auth/login', verified: false, }, @@ -415,28 +414,32 @@ describe('application URLs for SDKs other than Next.js', () => { ); }); - it('shows an unusable callback URL in the checklist instead of dropping it', async () => { + it('rejects an unusable callback URL before any setup', async () => { const emitter = createInstallerEventEmitter(); const events: string[] = []; emitter.on('app-urls:step', ({ step, status }) => events.push(`${step} ${status}`)); - const result = await configureOtherApplicationUrls( - { options: { ...options, redirectUri: 'ftp://localhost/callback' }, integration: 'go', emitter }, - 'client_a', - ); - expect(result).toBeUndefined(); - expect(events).toEqual(['initiate-login-uri skipped', 'sign-out-uri skipped']); + await expect( + configureOtherApplicationUrls( + { options: { ...options, redirectUri: 'ftp://localhost/callback' }, integration: 'go', emitter }, + 'client_a', + ), + ).rejects.toThrow('HTTP(S)'); + expect(events).toEqual([]); expect(configureAuthkitApplication).not.toHaveBeenCalled(); }); - it('leaves the settings for the dashboard instead of failing the install', async () => { + it('fails the install if the deferred callback cannot be registered', async () => { vi.mocked(configureAuthkitApplication).mockRejectedValue( new InstallDeclinedError('Automatic URL setup is restricted to sandbox environments.', 'callback_unregistered'), ); const emitter = createInstallerEventEmitter(); const events: string[] = []; emitter.on('app-urls:step', ({ step, status, detail }) => events.push(`${step} ${status} ${detail ?? ''}`.trim())); - const result = await configureOtherApplicationUrls({ options, integration: 'go', emitter }, 'client_a'); - expect(result?.verified).toBe(false); - expect(events).toContain('sign-out-uri skipped Automatic URL setup is restricted to sandbox environments.'); + await expect(configureOtherApplicationUrls({ options, integration: 'go', emitter }, 'client_a')).rejects.toThrow( + 'restricted to sandbox', + ); + expect(events).toContain('redirect-uri started'); + expect(events).toContain('cors-origin started'); + expect(events.some((event) => event.includes('done'))).toBe(false); }); }); diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index 06b1545e..40b286f5 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -54,7 +54,6 @@ import { generateCommitMessage as generateCommitMessageAi, generatePrDescription as generatePrDescriptionAi, } from './ai-content.js'; -import { autoConfigureWorkOSEnvironment } from './workos-management.js'; import { assertSupportedNextJsRouter, getNextJsRouter, @@ -62,7 +61,6 @@ import { } from '../integrations/nextjs/utils.js'; import { detectPort, getClientEnvPrefix, getSignInPath, resolveRedirectUri } from './port-detection.js'; import { hasClientSignInRoute } from './validation/validator.js'; -import { InstallDeclinedError } from './installer-errors.js'; import { writeEnvLocal } from './env-writer.js'; import { getRegistry } from './registry.js'; import { observeHostFailure } from './host-probe.js'; @@ -212,23 +210,10 @@ export async function configureInstallEnvironment( } const port = detectPort(integration, installerOptions.installDir); - // Next.js URL writes happen after code validation. That step chooses ONE - // target: the dashboard application, or an API-key-only callback without a session. - // Every other SDK registers its URLs here, client-only ones included: the - // integration's own run() skips it because this machine passes it the credentials. - const registersUrls = integration !== 'nextjs'; + // All URL writes wait until after the agent. Select one target then, even if + // credentials and the dashboard session refer to different environments or + // the session changes between preparation and application setup. if (isJavascript) step('env-vars', 'started'); - if (credentials.apiKey && registersUrls) { - await autoConfigureWorkOSEnvironment(credentials.apiKey, integration, port, { - homepageUrl: installerOptions.homepageUrl, - redirectUri: installerOptions.redirectUri, - onStep: step, - }); - } else if (registersUrls) { - for (const id of ['redirect-uri', 'cors-origin'] as const) { - step(id, 'skipped', 'No API key was available for this install.'); - } - } // Non-JavaScript agents write their own env files in the project's format. if (!isJavascript) return; @@ -267,21 +252,23 @@ export const NO_SIGN_IN_ROUTE_REASON = 'This framework has no fixed sign-in rout * * `configureAuthkitApplication` throws when the callback isn't registered, * which fails the install (and the items still running with it). When it - * returns, the callback is registered; the other two are verified together or - * not at all, with one reason covering both. Pass `includeRedirect: false` - * when the environment step already reported the callback. + * returns, the callback is registered; CORS has its own registration result, + * while the remaining settings share the overall verification result. */ export async function reportAppUrlSetup( emitter: Pick, configure: () => Promise, - { includeRedirect = true }: { includeRedirect?: boolean } = {}, + { includeRedirect = true, includeCors = false }: { includeRedirect?: boolean; includeCors?: boolean } = {}, ): Promise { const step = (id: SetupItemId, status: SetupItemStatus, detail?: string) => emitter.emit('app-urls:step', { step: id, status, ...(detail ? { detail } : {}) }); const ids = ['initiate-login-uri', 'sign-out-uri'] as const; for (const id of includeRedirect ? (['redirect-uri', ...ids] as const) : ids) step(id, 'started'); + if (includeCors) step('cors-origin', 'started'); const setup = await configure(); if (includeRedirect) step('redirect-uri', 'done'); + if (includeCors) + step('cors-origin', setup.corsRegistered ? 'done' : 'skipped', setup.corsRegistered ? undefined : setup.reason); const settle = (id: SetupItemId) => (setup.verified ? step(id, 'done') : step(id, 'skipped', setup.reason)); if (setup.initiateLoginUri === undefined) step('initiate-login-uri', 'skipped', setup.initiateLoginReason ?? setup.reason); @@ -291,10 +278,8 @@ export async function reportAppUrlSetup( } /** - * Set the sign-out and initiate login URIs for SDKs other than Next.js, after - * the agent has written the app. The environment step already registered the - * callback over the API key, so a failure here leaves the settings for the - * dashboard instead of failing an install whose code is done. + * Configure all URLs for SDKs other than Next.js after the agent, using one + * target selected here. An unregistered callback fails the install. */ export async function configureOtherApplicationUrls( context: Pick, @@ -311,32 +296,19 @@ export async function configureOtherApplicationUrls( initiateLoginReason = `Could not confirm a ${signInPath} client route that starts sign-in. Check the route before setting the Initiate login URI.`; signInPath = undefined; } - let setup: AuthkitApplicationSetup; - try { - setup = { - ...buildApplicationSetup({ - clientId, - redirectUri: resolveRedirectUri(integration, installerOptions), - homepageUrl: installerOptions.homepageUrl, - signInPath, - }), - ...(initiateLoginReason ? { initiateLoginReason } : {}), - }; - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - for (const step of ['initiate-login-uri', 'sign-out-uri'] as const) - context.emitter.emit('app-urls:step', { step, status: 'skipped', detail }); - return undefined; - } - return reportAppUrlSetup( - context.emitter, - () => - configureAuthkitApplication(setup, clientId, apiKey).catch((error: unknown) => { - if (!(error instanceof InstallDeclinedError)) throw error; - return { ...setup, verified: false, reason: error.message }; - }), - { includeRedirect: false }, - ); + const setup: AuthkitApplicationSetup = { + ...buildApplicationSetup({ + clientId, + redirectUri: resolveRedirectUri(integration, installerOptions), + homepageUrl: installerOptions.homepageUrl, + signInPath, + }), + corsOrigin: `http://localhost:${detectPort(integration, installerOptions.installDir)}`, + ...(initiateLoginReason ? { initiateLoginReason } : {}), + }; + return reportAppUrlSetup(context.emitter, () => configureAuthkitApplication(setup, clientId, apiKey), { + includeCors: true, + }); } /** Pick the installer adapter for this process's output mode and terminal. */ diff --git a/src/lib/workos-management.ts b/src/lib/workos-management.ts index f9946c05..924d07c3 100644 --- a/src/lib/workos-management.ts +++ b/src/lib/workos-management.ts @@ -84,7 +84,10 @@ async function createRedirectUri(apiKey: string, uri: string): Promise<{ success * Create a CORS origin in WorkOS. * Returns success on 201 or 409 (already exists). */ -async function createCorsOrigin(apiKey: string, origin: string): Promise<{ success: boolean; alreadyExists: boolean }> { +export async function createCorsOrigin( + apiKey: string, + origin: string, +): Promise<{ success: boolean; alreadyExists: boolean }> { const response = await workosRequest('POST', '/user_management/cors_origins', apiKey, { origin }); if (response.ok) { From 6514f046b439d1016d5edd07384cdc669f58389a Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 22:26:00 -0500 Subject: [PATCH 29/34] ci: warn explicitly when smoke credentials are unavailable --- scripts/smoke-auth-preflight.sh | 2 +- scripts/smoke-auth-preflight.spec.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/smoke-auth-preflight.sh b/scripts/smoke-auth-preflight.sh index 17eed0ba..9adbeba0 100644 --- a/scripts/smoke-auth-preflight.sh +++ b/scripts/smoke-auth-preflight.sh @@ -14,7 +14,7 @@ report() { } if [ -z "${WORKOS_SMOKE_API_KEY:-}" ]; then - report false 'Live authenticated checks NOT RUN: WORKOS_SMOKE_API_KEY is missing. Credential/API configuration needs investigation (fork PRs may have no secrets).' + report false '::warning::Live authenticated checks NOT RUN: WORKOS_SMOKE_API_KEY is missing. Credential/API configuration needs investigation (fork PRs may have no secrets).' exit 0 fi diff --git a/scripts/smoke-auth-preflight.spec.ts b/scripts/smoke-auth-preflight.spec.ts index d05a7553..8c443d63 100644 --- a/scripts/smoke-auth-preflight.spec.ts +++ b/scripts/smoke-auth-preflight.spec.ts @@ -119,6 +119,7 @@ describe('smoke authentication preflight (localhost only)', () => { expect(result.outputs).toBe('usable=false\n'); expect(result.notes).toContain('NOT RUN'); expect(result.notes).toContain('Credential/API configuration needs investigation'); + expect(result.stdout).toContain('::warning::'); expect(local.requests).toHaveLength(0); }); From 4d4cda9e5bde6704d2762c451873a4a5829403ba Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 22:48:41 -0500 Subject: [PATCH 30/34] Revert "ci: warn explicitly when smoke credentials are unavailable" This reverts commit 305270809184ad84429e80fe9a7ba50821538935. --- scripts/smoke-auth-preflight.sh | 2 +- scripts/smoke-auth-preflight.spec.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/smoke-auth-preflight.sh b/scripts/smoke-auth-preflight.sh index 9adbeba0..17eed0ba 100644 --- a/scripts/smoke-auth-preflight.sh +++ b/scripts/smoke-auth-preflight.sh @@ -14,7 +14,7 @@ report() { } if [ -z "${WORKOS_SMOKE_API_KEY:-}" ]; then - report false '::warning::Live authenticated checks NOT RUN: WORKOS_SMOKE_API_KEY is missing. Credential/API configuration needs investigation (fork PRs may have no secrets).' + report false 'Live authenticated checks NOT RUN: WORKOS_SMOKE_API_KEY is missing. Credential/API configuration needs investigation (fork PRs may have no secrets).' exit 0 fi diff --git a/scripts/smoke-auth-preflight.spec.ts b/scripts/smoke-auth-preflight.spec.ts index 8c443d63..d05a7553 100644 --- a/scripts/smoke-auth-preflight.spec.ts +++ b/scripts/smoke-auth-preflight.spec.ts @@ -119,7 +119,6 @@ describe('smoke authentication preflight (localhost only)', () => { expect(result.outputs).toBe('usable=false\n'); expect(result.notes).toContain('NOT RUN'); expect(result.notes).toContain('Credential/API configuration needs investigation'); - expect(result.stdout).toContain('::warning::'); expect(local.requests).toHaveLength(0); }); From 794b1af812950d4c2e690fe3e58b6d9725f6d1a3 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 22:48:41 -0500 Subject: [PATCH 31/34] Revert "ci: distinguish unusable smoke credentials from CLI regressions" This reverts commit eaae2f4abe72152b16a1971edf08bd1579bf7b7e. --- .github/workflows/test.yml | 45 +++++-- scripts/smoke-auth-preflight.sh | 47 ------- scripts/smoke-auth-preflight.spec.ts | 184 --------------------------- 3 files changed, 34 insertions(+), 242 deletions(-) delete mode 100644 scripts/smoke-auth-preflight.sh delete mode 100644 scripts/smoke-auth-preflight.spec.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 41ba4e72..53d523fd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -78,30 +78,53 @@ jobs: # Authenticated command execution against a real WorkOS staging # environment: list plus a create → get → delete round-trip. The secret - # is a dedicated staging-environment API key. Only a successful direct - # read-only probe enables live checks; missing/rejected credentials are - # reported as NOT RUN, while other preflight failures fail the job. - - name: Preflight smoke authentication without the CLI - id: smoke-auth-preflight - env: - WORKOS_SMOKE_API_KEY: ${{ secrets.WORKOS_SMOKE_API_KEY }} - WORKOS_SMOKE_API_URL: ${{ secrets.WORKOS_SMOKE_API_URL }} - run: sh scripts/smoke-auth-preflight.sh - + # is a dedicated staging-environment API key; fork PRs receive no + # secrets, so this skips there instead of failing. - name: Smoke test authenticated commands id: authenticated-smoke - if: steps.smoke-auth-preflight.outputs.usable == 'true' env: WORKOS_API_KEY: ${{ secrets.WORKOS_SMOKE_API_KEY }} # Optional: set when the smoke key belongs to a non-default API host # (e.g. an internal staging deployment). WORKOS_SMOKE_API_URL: ${{ secrets.WORKOS_SMOKE_API_URL }} run: | + if [ -z "$WORKOS_API_KEY" ]; then + echo "Skipped: WORKOS_SMOKE_API_KEY secret not available (fork PR or not yet configured)" + exit 0 + fi if [ -n "$WORKOS_SMOKE_API_URL" ]; then export WORKOS_API_URL="$WORKOS_SMOKE_API_URL" fi sh scripts/command-smoke.sh ./dist/workos + # Independent, read-only control when the CLI smoke fails: distinguish + # CLI credential handling from the API rejecting the CI credential. + # Never log the credential, response body, or full response headers. + - name: Diagnose smoke authentication without the CLI + if: ${{ failure() && steps.authenticated-smoke.outcome == 'failure' }} + env: + WORKOS_API_KEY: ${{ secrets.WORKOS_SMOKE_API_KEY }} + WORKOS_SMOKE_API_URL: ${{ secrets.WORKOS_SMOKE_API_URL }} + run: | + set +x + if [ -z "$WORKOS_API_KEY" ]; then + echo "Direct API probe skipped: smoke credential unavailable" + exit 0 + fi + headers=$(mktemp) + trap 'rm -f "$headers"' EXIT + base="${WORKOS_SMOKE_API_URL:-https://api.workos.com}" + curl_exit=0 + status=$(curl --silent --connect-timeout 10 --max-time 20 \ + --proto '=http,https' \ + --header "Authorization: Bearer $WORKOS_API_KEY" \ + --dump-header "$headers" --output /dev/null \ + --write-out '%{http_code}' \ + "${base%/}/connections?limit=1" 2>/dev/null) || curl_exit=$? + printf 'Direct GET /connections: HTTP %s; curl exit %s\n' "$status" "$curl_exit" + # No --location: do not follow redirects with credentials. + awk 'tolower($1) == "x-request-id:" { gsub(/\r/, "", $2); if ($2 ~ /^[A-Za-z0-9_-]+$/) print "API request ID: " $2 }' "$headers" + # Publish the generated packages to a throwaway local registry and run # the real user flows — `npx workos`, `npm install -g workos`, platform # selection, and the launcher's no-binary error path — from a hermetic diff --git a/scripts/smoke-auth-preflight.sh b/scripts/smoke-auth-preflight.sh deleted file mode 100644 index 17eed0ba..00000000 --- a/scripts/smoke-auth-preflight.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/bin/sh -# Read-only credential eligibility check. Never log credentials or API payloads. -set +x -set -eu - -report() { - printf '%s\n' "$2" - if [ -n "${GITHUB_OUTPUT:-}" ]; then - printf 'usable=%s\n' "$1" >> "$GITHUB_OUTPUT" - fi - if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then - printf '%s\n' "$2" >> "$GITHUB_STEP_SUMMARY" - fi -} - -if [ -z "${WORKOS_SMOKE_API_KEY:-}" ]; then - report false 'Live authenticated checks NOT RUN: WORKOS_SMOKE_API_KEY is missing. Credential/API configuration needs investigation (fork PRs may have no secrets).' - exit 0 -fi - -base="${WORKOS_SMOKE_API_URL:-https://api.workos.com}" -curl_exit=0 -# -q must be first: ignore curlrc (including redirects, retries and tracing). -# No --location, retries, response headers, body, or curl diagnostics. -status=$(curl -q --silent --connect-timeout 10 --max-time 20 \ - --proto '=http,https' --request GET \ - --header "Authorization: Bearer $WORKOS_SMOKE_API_KEY" \ - --output /dev/null --write-out '%{http_code}' \ - "${base%/}/connections?limit=1" 2>/dev/null) || curl_exit=$? - -if [ "$curl_exit" -ne 0 ]; then - report false '::error::Smoke credential preflight transport failure. Live authenticated checks NOT RUN; investigate credential/API connectivity.' - exit 1 -fi - -case "$status" in - 200) - report true 'Smoke credential preflight HTTP 200: eligible for live authenticated checks; this is not a live smoke pass.' - ;; - 401) - report false '::warning::Smoke credential preflight HTTP 401: live authenticated checks NOT RUN. Investigate the CI credential/API configuration; live checks have not passed.' - ;; - *) - report false "::error::Smoke credential preflight HTTP $status (expected 200 or 401). Live authenticated checks NOT RUN; investigate the API response." - exit 1 - ;; -esac diff --git a/scripts/smoke-auth-preflight.spec.ts b/scripts/smoke-auth-preflight.spec.ts deleted file mode 100644 index d05a7553..00000000 --- a/scripts/smoke-auth-preflight.spec.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { execFile } from 'node:child_process'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { createServer, type Server } from 'node:http'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; -import { parse } from 'yaml'; - -const script = resolve('scripts/smoke-auth-preflight.sh'); -const key = 'fake-localhost-only-secret'; -const body = 'private-response-body'; -const header = 'private-response-header'; -const servers: Server[] = []; -const dirs: string[] = []; - -async function fixture(status: number, disconnect = false) { - const requests: { method?: string; url?: string; authorization?: string }[] = []; - const server = createServer((req, res) => { - requests.push({ method: req.method, url: req.url, authorization: req.headers.authorization }); - if (disconnect) { - req.socket.destroy(); - return; - } - res.writeHead(status, { Location: '/redirect-target', 'X-Private': header }); - res.end(`${body} ${key}`); - }); - servers.push(server); - await new Promise((done) => server.listen(0, '127.0.0.1', done)); - const address = server.address(); - if (!address || typeof address === 'string') throw new Error('Missing local address'); - return { url: `http://127.0.0.1:${address.port}`, requests }; -} - -async function run(url: string, credential = key, githubFiles = true) { - const dir = await mkdtemp(join(tmpdir(), 'smoke-preflight-')); - dirs.push(dir); - const output = join(dir, 'output'); - const summary = join(dir, 'summary'); - // A user curlrc must not enable redirects, retries, or verbose secret logging. - await writeFile(join(dir, '.curlrc'), 'location\nverbose\nretry = 2\n'); - await writeFile(output, ''); - await writeFile(summary, ''); - const result = await new Promise<{ code: number; stdout: string; stderr: string }>((done) => { - execFile( - 'sh', - [script], - { - timeout: 5000, - // Do not inherit credentials, proxy configuration or curl settings. - env: { - PATH: process.env.PATH, - HOME: dir, - CURL_HOME: dir, - NO_PROXY: '*', - WORKOS_SMOKE_API_KEY: credential, - WORKOS_SMOKE_API_URL: `${url}/`, - ...(githubFiles ? { GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary } : {}), - }, - }, - (error, stdout, stderr) => { - done({ code: error ? (typeof error.code === 'number' ? error.code : -1) : 0, stdout, stderr }); - }, - ); - }); - const outputs = await readFile(output, 'utf8'); - const notes = await readFile(summary, 'utf8'); - const logs = result.stdout + result.stderr + outputs + notes; - for (const sensitive of [key, body, header, 'Authorization:', 'X-Private:']) { - expect(logs).not.toContain(sensitive); - } - expect(result.stderr).toBe(''); - return { ...result, outputs, notes }; -} - -afterEach(async () => { - await Promise.all( - servers.splice(0).map( - (server) => - new Promise((done, reject) => { - server.close((error) => (error ? reject(error) : done())); - }), - ), - ); - await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); -}); - -describe('smoke authentication preflight (localhost only)', () => { - it('enables live smoke only for HTTP 200 with a single authenticated GET', async () => { - const local = await fixture(200); - const result = await run(local.url); - expect(result.code).toBe(0); - expect(result.outputs).toBe('usable=true\n'); - expect(result.notes).toContain('not a live smoke pass'); - expect(local.requests).toEqual([ - { - method: 'GET', - url: '/connections?limit=1', - authorization: `Bearer ${key}`, - }, - ]); - }); - - it('transparently skips rejected credentials with a GitHub warning and summary', async () => { - const local = await fixture(401); - const result = await run(local.url); - expect(result.code).toBe(0); - expect(result.outputs).toBe('usable=false\n'); - expect(result.stdout).toContain('::warning::'); - expect(result.notes).toContain('HTTP 401'); - expect(result.notes).toContain('NOT RUN'); - expect(result.notes).toContain('Investigate the CI credential/API'); - expect(local.requests).toHaveLength(1); - }); - - it('skips missing credentials without a request', async () => { - const local = await fixture(200); - const result = await run(local.url, ''); - expect(result.code).toBe(0); - expect(result.outputs).toBe('usable=false\n'); - expect(result.notes).toContain('NOT RUN'); - expect(result.notes).toContain('Credential/API configuration needs investigation'); - expect(local.requests).toHaveLength(0); - }); - - it.each([204, 301, 302, 307, 308, 403, 429, 500, 503])( - 'fails closed for HTTP %s, without following or retrying', - async (status) => { - const local = await fixture(status); - const result = await run(local.url); - expect(result.code).toBe(1); - expect(result.outputs).toBe('usable=false\n'); - expect(result.stdout).toContain('::error::'); - expect(result.notes).toContain('NOT RUN'); - expect(local.requests).toHaveLength(1); - expect(local.requests[0].url).toBe('/connections?limit=1'); - }, - ); - - it('fails red on transport failure', async () => { - const local = await fixture(200, true); - const result = await run(local.url); - expect(result.code).toBe(1); - expect(result.outputs).toBe('usable=false\n'); - expect(result.notes).toContain('transport failure'); - expect(local.requests).toHaveLength(1); - }); - - it('also works without GitHub output files', async () => { - const local = await fixture(200); - const result = await run(local.url, key, false); - expect(result.code).toBe(0); - expect(result.stdout).toContain('eligible'); - }); - - it('gates the existing live CLI smoke without suppressing its failures', async () => { - const workflow = parse(await readFile('.github/workflows/test.yml', 'utf8')); - const steps = workflow.jobs.test.steps; - const preflight = steps.findIndex((step: { id?: string }) => step.id === 'smoke-auth-preflight'); - const live = steps.findIndex((step: { id?: string }) => step.id === 'authenticated-smoke'); - expect(preflight).toBeGreaterThan(-1); - expect(live).toBeGreaterThan(preflight); - expect(steps[preflight].run).toBe('sh scripts/smoke-auth-preflight.sh'); - expect(steps[live].if).toBe("steps.smoke-auth-preflight.outputs.usable == 'true'"); - expect(steps[live]['continue-on-error']).toBeUndefined(); - expect(steps[preflight]['continue-on-error']).toBeUndefined(); - expect(steps[live].env.WORKOS_API_KEY).toBe(steps[preflight].env.WORKOS_SMOKE_API_KEY); - expect(steps[live].env.WORKOS_SMOKE_API_URL).toBe(steps[preflight].env.WORKOS_SMOKE_API_URL); - expect(steps[live].run.trim().endsWith('sh scripts/command-smoke.sh ./dist/workos')).toBe(true); - // Execute the workflow wrapper with a failing local CLI-smoke stand-in. - // No CLI, credentials, or network are used; its actual exit must survive. - const exitCode = await new Promise((done) => { - execFile( - 'sh', - ['-e', '-c', `sh() { return 17; };\n${steps[live].run}`], - { env: { PATH: process.env.PATH, WORKOS_SMOKE_API_URL: 'http://127.0.0.1:1' } }, - (error) => done(error && typeof error.code === 'number' ? error.code : 0), - ); - }); - expect(exitCode).toBe(17); - expect(steps.some((step: { name?: string }) => step.name === 'Diagnose smoke authentication without the CLI')).toBe( - false, - ); - }); -}); From 3ad483172e3057d664dc9f27154b3194ee2a71e9 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 22:48:41 -0500 Subject: [PATCH 32/34] Revert "ci: diagnose smoke authentication with a direct read-only request" This reverts commit 631322364c0bdad446fbe552b0478aa27d1de898. --- .github/workflows/test.yml | 29 ----------------------------- 1 file changed, 29 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 53d523fd..3cf66e85 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -81,7 +81,6 @@ jobs: # is a dedicated staging-environment API key; fork PRs receive no # secrets, so this skips there instead of failing. - name: Smoke test authenticated commands - id: authenticated-smoke env: WORKOS_API_KEY: ${{ secrets.WORKOS_SMOKE_API_KEY }} # Optional: set when the smoke key belongs to a non-default API host @@ -97,34 +96,6 @@ jobs: fi sh scripts/command-smoke.sh ./dist/workos - # Independent, read-only control when the CLI smoke fails: distinguish - # CLI credential handling from the API rejecting the CI credential. - # Never log the credential, response body, or full response headers. - - name: Diagnose smoke authentication without the CLI - if: ${{ failure() && steps.authenticated-smoke.outcome == 'failure' }} - env: - WORKOS_API_KEY: ${{ secrets.WORKOS_SMOKE_API_KEY }} - WORKOS_SMOKE_API_URL: ${{ secrets.WORKOS_SMOKE_API_URL }} - run: | - set +x - if [ -z "$WORKOS_API_KEY" ]; then - echo "Direct API probe skipped: smoke credential unavailable" - exit 0 - fi - headers=$(mktemp) - trap 'rm -f "$headers"' EXIT - base="${WORKOS_SMOKE_API_URL:-https://api.workos.com}" - curl_exit=0 - status=$(curl --silent --connect-timeout 10 --max-time 20 \ - --proto '=http,https' \ - --header "Authorization: Bearer $WORKOS_API_KEY" \ - --dump-header "$headers" --output /dev/null \ - --write-out '%{http_code}' \ - "${base%/}/connections?limit=1" 2>/dev/null) || curl_exit=$? - printf 'Direct GET /connections: HTTP %s; curl exit %s\n' "$status" "$curl_exit" - # No --location: do not follow redirects with credentials. - awk 'tolower($1) == "x-request-id:" { gsub(/\r/, "", $2); if ($2 ~ /^[A-Za-z0-9_-]+$/) print "API request ID: " $2 }' "$headers" - # Publish the generated packages to a throwaway local registry and run # the real user flows — `npx workos`, `npm install -g workos`, platform # selection, and the launcher's no-binary error path — from a hermetic From efe729e428a97e2650a9053b2abea795f2bdcfc3 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 22:58:55 -0500 Subject: [PATCH 33/34] refactor(install): leave client login verification to browser testing Remove the client-route AST analyzer and its direct Babel dependency. Keep generating a public login route using app conventions, but leave the client-only Initiate login URI unchanged until browser verification. Treat scan limits as warnings while preserving callback/env checks and mandatory registration.\n\nRetain sandbox/homepage protections and single-target writes. Derive CORS from the callback origin and report read-back sign-out independently of pending login verification. CI smoke changes are split onto ci/smoke-auth-preflight. --- README.md | 2 +- bun.lock | 1 - package.json | 1 - src/lib/agent-runner.spec.ts | 9 +- src/lib/authkit-application-setup.spec.ts | 38 ++ src/lib/authkit-application-setup.ts | 23 +- src/lib/completion-data.ts | 4 +- src/lib/run-with-core.setup.spec.ts | 108 ++++-- src/lib/run-with-core.ts | 29 +- src/lib/sign-in-route.ts | 6 +- src/lib/validation/client-sign-in.spec.ts | 124 ------- src/lib/validation/client-sign-in.ts | 424 ---------------------- src/lib/validation/client-source.spec.ts | 185 ++-------- src/lib/validation/validator.spec.ts | 24 +- src/lib/validation/validator.ts | 64 +--- 15 files changed, 229 insertions(+), 813 deletions(-) delete mode 100644 src/lib/validation/client-sign-in.spec.ts delete mode 100644 src/lib/validation/client-sign-in.ts diff --git a/README.md b/README.md index e926c6ec..e2e2932c 100644 --- a/README.md +++ b/README.md @@ -718,7 +718,7 @@ OAuth credentials are stored in the system keychain (with `~/.workos/credentials 5. **Uses AI** (Claude) to install the SDK and create auth routes, middleware, and UI 6. **Checks** the generated integration 7. **Configures sandbox URLs** after the agent, using one WorkOS target: the client-ID-matched dashboard application, or the API-key-only fallback. Existing settings are preserved unless an explicit override is allowed; unsupported or unverified settings are reported for manual setup. -8. **Reports** what was configured and what still needs attention. Test sign-in, sign-out, and invitation/password-reset flows in the browser before treating the integration as complete. +8. **Reports** what was configured and what still needs attention. For React and vanilla JS, the agent creates `/login`, but the installer leaves the Initiate login URI unchanged: open `/login` while signed out, confirm it starts sign-in without a click, then set that URL in the WorkOS dashboard. Static source checks cannot verify client routing; unsupported layouts or scan limits do not by themselves fail installation. Test sign-in, sign-out, and invitation/password-reset flows in the browser before treating the integration as complete. ## Telemetry diff --git a/bun.lock b/bun.lock index bf0b6a76..8396c481 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,6 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "~0.3.0", "@anthropic-ai/sdk": "^0.124.0", - "@babel/parser": "7.29.7", "@inkjs/ui": "^2.0.0", "@inquirer/prompts": "^8.5.2", "@napi-rs/keyring": "^1.2.0", diff --git a/package.json b/package.json index ce5cbd01..3ee7876b 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "~0.3.0", "@anthropic-ai/sdk": "^0.124.0", - "@babel/parser": "7.29.7", "@inkjs/ui": "^2.0.0", "@inquirer/prompts": "^8.5.2", "@napi-rs/keyring": "^1.2.0", diff --git a/src/lib/agent-runner.spec.ts b/src/lib/agent-runner.spec.ts index 3be837f3..2c6725c6 100644 --- a/src/lib/agent-runner.spec.ts +++ b/src/lib/agent-runner.spec.ts @@ -125,10 +125,11 @@ describe('installer prompt', () => { expect(prompt).toContain('## Sign-in route (Initiate login URI)'); expect(prompt).toContain('the app origin plus /login'); expect(prompt).toContain('add a /login client route'); - expect(prompt).toContain("if (window.location.pathname === '/login') { void signIn(); }"); - expect(prompt).toContain('Keep the call directly in that branch, not in a click handler or another function.'); - expect(prompt).toContain('does not need a second pathname guard'); - expect(prompt).toContain('relative default or named import'); + expect(prompt).toContain("Use the app's existing router and component conventions"); + expect(prompt).toContain('without a click'); + expect(prompt).toContain('leaves the Initiate login URI unchanged'); + expect(prompt).toContain('confirm automatic sign-in in the browser'); + expect(prompt).not.toContain('relative default or named import'); expect(prompt).toContain('VITE_WORKOS_REDIRECT_URI for Vite'); }); diff --git a/src/lib/authkit-application-setup.spec.ts b/src/lib/authkit-application-setup.spec.ts index 448fc321..ea22acb9 100644 --- a/src/lib/authkit-application-setup.spec.ts +++ b/src/lib/authkit-application-setup.spec.ts @@ -366,6 +366,8 @@ describe('native application URL setup', () => { const result = await configureAuthkitApplication(withoutSignIn, setup.clientId); expect(result.verified).toBe(false); expect(result.callbackRegistered).toBe(true); + expect(result.signOutRegistered).toBe(true); + expect(applicationSetupNextSteps(result)).toContain(`Sign-out URI: ${setup.signOutUri} (registered)`); expect(result.initiateLoginReason).toContain('No client sign-in route'); expect(result.reason).toBe(result.initiateLoginReason); expect(application.appHomepageUrl).toBe('http://localhost:4000'); @@ -385,6 +387,42 @@ describe('native application URL setup', () => { expect(result.reason).toBe(initiateLoginReason); expect(result.reason).not.toContain('differs from this app'); expect(application.initiateLoginUri).toBe('http://localhost:4000/login'); + expect(applicationSetupNextSteps(result)).toContain( + `Initiate login URI: not configured by the installer. ${initiateLoginReason}`, + ); + }); + + it('does not mark sign-out registered when its readback fails and initiate login is pending', async () => { + const { initiateLoginUri: _omitted, ...withoutSignIn } = setup; + const respond = vi.mocked(dashboardGraphqlRequest).getMockImplementation()!; + vi.mocked(dashboardGraphqlRequest).mockImplementation(async (name, options) => { + if (name === 'setAuthkitApplicationLogoutUris') + return { setUserlandApplicationLogoutUris: { __typename: 'LogoutUrisSet' } }; + return respond(name, options); + }); + const result = await configureAuthkitApplication(withoutSignIn, setup.clientId); + expect(result.verified).toBe(false); + expect(result.signOutRegistered).toBe(false); + expect(result.reason).toContain('URL read-back did not match'); + expect(result.reason).toContain('No client sign-in route'); + expect(applicationSetupNextSteps(result)).toContain( + `Sign-out URI: ${setup.signOutUri} (not registered or verified)`, + ); + }); + + it('does not trust a sign-out result from a previous attempt on the API-only path', async () => { + vi.mocked(refreshIfExpired).mockResolvedValue(null); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('{}', { status: 201 })), + ); + const result = await configureAuthkitApplication( + { ...setup, signOutRegistered: true }, + setup.clientId, + 'sk_test_a', + ); + expect(result.signOutRegistered).toBe(false); + expect(result.verified).toBe(false); }); it('fills an empty dashboard homepage with the callback origin', async () => { diff --git a/src/lib/authkit-application-setup.ts b/src/lib/authkit-application-setup.ts index 923d60e0..a418165a 100644 --- a/src/lib/authkit-application-setup.ts +++ b/src/lib/authkit-application-setup.ts @@ -20,6 +20,8 @@ export interface AuthkitApplicationSetup { /** Browser origin to add without replacing existing allowed origins. */ corsOrigin?: string; corsRegistered?: boolean; + /** Sign-out was read back independently of pending initiate-login setup. */ + signOutRegistered?: boolean; verified: boolean; /** The callback was registered; this alone does not verify the other URLs or browser flows. */ callbackRegistered?: boolean; @@ -110,7 +112,7 @@ export async function configureAuthkitApplication( }; } // Never trust incoming registration flags; they belong to an earlier attempt. - setup = { ...setup, ...(setup.corsOrigin !== undefined ? { corsRegistered: false } : {}) }; + setup = { ...setup, signOutRegistered: false, ...(setup.corsOrigin !== undefined ? { corsRegistered: false } : {}) }; let callbackRegistered = false; const pending = (reason: string): AuthkitApplicationSetup => { if (!callbackRegistered) { @@ -378,22 +380,27 @@ export async function configureAuthkitApplication( setup = { ...setup, corsRegistered: false }; return pending('CORS read-back did not match the required settings.'); } - if (reasons.length) return pending(reasons.join(' ')); + setup = { + ...setup, + signOutRegistered: + saved.logoutUris.filter((uri) => uri.isDefault).length === 1 && + saved.logoutUris.some((uri) => isSignOutDestination(uri.uri) && uri.isDefault) && + original.logoutUris.every((old) => + saved.logoutUris.some((uri) => uri.uri === old.uri && (!old.isDefault || uri.isDefault)), + ), + }; if ( !original.redirectUris.every((old) => saved.redirectUris.some((uri) => uri.uri === old.uri && (!old.isDefault || uri.isDefault)), ) || - !original.logoutUris.every((old) => - saved.logoutUris.some((uri) => uri.uri === old.uri && (!old.isDefault || uri.isDefault)), - ) || - saved.logoutUris.filter((uri) => uri.isDefault).length !== 1 || - !saved.logoutUris.some((uri) => isSignOutDestination(uri.uri) && uri.isDefault) || + !setup.signOutRegistered || differsFromInitiate(saved.initiateLoginUri) || saved.appHomepageUrl !== homepageValue ) - return pending( + reasons.push( 'URL read-back did not match the required settings. Check the dashboard before testing authentication.', ); + if (reasons.length) return pending(reasons.join(' ')); return { ...setup, signOutUri: saved.logoutUris.find((uri) => uri.isDefault)!.uri, diff --git a/src/lib/completion-data.ts b/src/lib/completion-data.ts index 760e95ce..1dc8a26d 100644 --- a/src/lib/completion-data.ts +++ b/src/lib/completion-data.ts @@ -12,10 +12,10 @@ export function applicationSetupNextSteps(setup: AuthkitApplicationSetup): strin ...(setup.corsOrigin !== undefined ? [`CORS origin: ${setup.corsOrigin} (${setup.corsRegistered ? 'registered' : 'not registered or verified'})`] : []), - `Sign-out URI: ${setup.signOutUri}`, + `Sign-out URI: ${setup.signOutUri} (${setup.signOutRegistered || setup.verified ? 'registered' : 'not registered or verified'})`, setup.initiateLoginUri !== undefined ? `Initiate login URI: ${setup.initiateLoginUri} (starts sign-in; never use the callback URI)` - : 'Initiate login URI: not set. Point it at the app route that starts sign-in (never the callback URI).', + : `Initiate login URI: not configured by the installer. ${setup.initiateLoginReason ?? 'Verify the app route starts sign-in before setting it in the dashboard (never use the callback URI).'}`, ...(setup.homepageUrl !== undefined ? [`Homepage URL: ${setup.homepageUrl}`] : []), 'Test sign-in, sign-out, protected-page access, and a password-reset or invitation login before calling the integration complete.', ]; diff --git a/src/lib/run-with-core.setup.spec.ts b/src/lib/run-with-core.setup.spec.ts index 0553425e..a79c07bf 100644 --- a/src/lib/run-with-core.setup.spec.ts +++ b/src/lib/run-with-core.setup.spec.ts @@ -272,6 +272,21 @@ describe('dashboard checklist reporting', () => { expect(events).toContain('app-urls:step sign-out-uri skipped (Manual setup needed.)'); }); + it('reports saved sign-out independently of pending client login verification', async () => { + const { emitter, events } = record(); + const { initiateLoginUri: _omitted, ...withoutSignIn } = setup; + await reportAppUrlSetup(emitter, async () => ({ + ...withoutSignIn, + verified: false, + callbackRegistered: true, + signOutRegistered: true, + initiateLoginReason: 'Check /login in the browser.', + reason: 'Check /login in the browser.', + })); + expect(events).toContain('app-urls:step sign-out-uri done'); + expect(events).toContain('app-urls:step initiate-login-uri skipped (Check /login in the browser.)'); + }); + it('flags the unverified URLs with the setup reason', async () => { const { emitter, events } = record(); const result = await reportAppUrlSetup(emitter, async () => ({ @@ -366,31 +381,38 @@ describe('application URLs for SDKs other than Next.js', () => { ); }); - it('saves the Vite /login route once the app serves it', async () => { - await mkdir(join(directory, 'src'), { recursive: true }); - await writeFile(join(directory, 'package.json'), '{"dependencies":{"@workos-inc/authkit-react":"1"}}'); - await writeFile(join(directory, '.env.local'), 'VITE_WORKOS_CLIENT_ID=client_a\n'); - await writeFile( - join(directory, 'src/main.tsx'), - "import { AuthKitProvider, useAuth } from '@workos-inc/authkit-react';\n;\n", - ); - await writeFile( - join(directory, 'src/App.tsx'), - "if (window.location.pathname === '/login') signIn();\nconst { signIn } = useAuth();\n", - ); - await writeFile( - join(directory, 'src/config.ts'), - 'export const redirectUri = import.meta.env.VITE_WORKOS_REDIRECT_URI;\n', - ); - await configureOtherApplicationUrls( - { options, integration: 'react', emitter: createInstallerEventEmitter() }, - 'client_a', - 'sk_test_a', - ); - expect(vi.mocked(configureAuthkitApplication).mock.calls[0][0].initiateLoginUri).toBe( - 'http://localhost:5173/login', - ); - }); + it.each(['react', 'vanilla-js'])( + 'leaves %s initiate login unchanged even when source looks correct', + async (integration) => { + await mkdir(join(directory, 'src'), { recursive: true }); + await writeFile(join(directory, 'package.json'), '{"dependencies":{"@workos-inc/authkit-react":"1"}}'); + await writeFile(join(directory, '.env.local'), 'VITE_WORKOS_CLIENT_ID=client_a\n'); + await writeFile( + join(directory, 'src/main.tsx'), + "import { AuthKitProvider, useAuth } from '@workos-inc/authkit-react';\n;\n", + ); + await writeFile( + join(directory, 'src/App.tsx'), + "if (window.location.pathname === '/login') signIn();\nconst { signIn } = useAuth();\n", + ); + await writeFile( + join(directory, 'src/config.ts'), + 'export const redirectUri = import.meta.env.VITE_WORKOS_REDIRECT_URI;\n', + ); + await configureOtherApplicationUrls( + { options, integration, emitter: createInstallerEventEmitter() }, + 'client_a', + 'sk_test_a', + ); + expect(vi.mocked(configureAuthkitApplication).mock.calls[0][0]).not.toHaveProperty('initiateLoginUri'); + expect(vi.mocked(configureAuthkitApplication).mock.calls[0][0]).toMatchObject({ + redirectUri: 'http://localhost:5173/callback', + corsOrigin: 'http://localhost:5173', + signOutUri: 'http://localhost:5173/', + initiateLoginReason: expect.stringContaining('requires browser verification'), + }); + }, + ); it('does not save a Vite /login route the app only links to', async () => { await mkdir(join(directory, 'src'), { recursive: true }); @@ -403,17 +425,49 @@ describe('application URLs for SDKs other than Next.js', () => { expect(vi.mocked(configureAuthkitApplication).mock.calls[0][0]).not.toHaveProperty('initiateLoginUri'); }); - it('does not point the dashboard at a Vite /login route the app lacks, and says why', async () => { + it('reports client route verification as pending, not a missing route', async () => { const emitter = createInstallerEventEmitter(); const events: string[] = []; emitter.on('app-urls:step', ({ step, status, detail }) => events.push(`${step} ${status} ${detail ?? ''}`.trim())); await configureOtherApplicationUrls({ options, integration: 'react', emitter }, 'client_a', 'sk_test_a'); expect(vi.mocked(configureAuthkitApplication).mock.calls[0][0]).not.toHaveProperty('initiateLoginUri'); expect(events).toContain( - 'initiate-login-uri skipped Could not confirm a /login client route that starts sign-in. Check the route before setting the Initiate login URI.', + 'initiate-login-uri skipped Client-side /login requires browser verification. Confirm it starts sign-in without a click, then set the Initiate login URI in the WorkOS dashboard. The existing setting was left unchanged.', ); }); + it('uses the explicit callback origin for CORS as well as sign-out', async () => { + await configureOtherApplicationUrls( + { + options: { ...options, redirectUri: 'https://dev.example.com/callback' }, + integration: 'react', + emitter: createInstallerEventEmitter(), + }, + 'client_a', + 'sk_test_a', + ); + expect(vi.mocked(configureAuthkitApplication).mock.calls[0][0]).toMatchObject({ + redirectUri: 'https://dev.example.com/callback', + corsOrigin: 'https://dev.example.com', + signOutUri: 'https://dev.example.com/', + }); + }); + + it('still rejects a callback that collides with the client login route', async () => { + await expect( + configureOtherApplicationUrls( + { + options: { ...options, redirectUri: 'http://localhost:5173/login' }, + integration: 'react', + emitter: createInstallerEventEmitter(), + }, + 'client_a', + 'sk_test_a', + ), + ).rejects.toThrow('The OAuth callback cannot use /login'); + expect(configureAuthkitApplication).not.toHaveBeenCalled(); + }); + it('rejects an unusable callback URL before any setup', async () => { const emitter = createInstallerEventEmitter(); const events: string[] = []; diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index 40b286f5..9eee52a6 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -60,7 +60,6 @@ import { assertNextjsSignInRouteAvailable, } from '../integrations/nextjs/utils.js'; import { detectPort, getClientEnvPrefix, getSignInPath, resolveRedirectUri } from './port-detection.js'; -import { hasClientSignInRoute } from './validation/validator.js'; import { writeEnvLocal } from './env-writer.js'; import { getRegistry } from './registry.js'; import { observeHostFailure } from './host-probe.js'; @@ -252,8 +251,8 @@ export const NO_SIGN_IN_ROUTE_REASON = 'This framework has no fixed sign-in rout * * `configureAuthkitApplication` throws when the callback isn't registered, * which fails the install (and the items still running with it). When it - * returns, the callback is registered; CORS has its own registration result, - * while the remaining settings share the overall verification result. + * returns, the callback is registered; CORS and sign-out have their own results, + * so pending initiate-login setup does not hide settings already saved. */ export async function reportAppUrlSetup( emitter: Pick, @@ -273,7 +272,8 @@ export async function reportAppUrlSetup( if (setup.initiateLoginUri === undefined) step('initiate-login-uri', 'skipped', setup.initiateLoginReason ?? setup.reason); else settle('initiate-login-uri'); - settle('sign-out-uri'); + if (setup.signOutRegistered) step('sign-out-uri', 'done'); + else settle('sign-out-uri'); return setup; } @@ -288,24 +288,25 @@ export async function configureOtherApplicationUrls( ): Promise { const { options: installerOptions, integration } = context; if (!integration || integration === 'nextjs') return undefined; - let signInPath = getSignInPath(integration); - let initiateLoginReason = signInPath ? undefined : NO_SIGN_IN_ROUTE_REASON; - // A client-only route has no file the guide fixes, so save it only once the app serves it. + const signInPath = getSignInPath(integration); const clientOnly = (await getRegistry()).get(integration)?.config.environment.requiresApiKey === false; - if (signInPath && clientOnly && !(await hasClientSignInRoute(installerOptions.installDir, signInPath))) { - initiateLoginReason = `Could not confirm a ${signInPath} client route that starts sign-in. Check the route before setting the Initiate login URI.`; - signInPath = undefined; - } + const redirectUri = resolveRedirectUri(integration, installerOptions); const setup: AuthkitApplicationSetup = { ...buildApplicationSetup({ clientId, - redirectUri: resolveRedirectUri(integration, installerOptions), + redirectUri, homepageUrl: installerOptions.homepageUrl, signInPath, }), - corsOrigin: `http://localhost:${detectPort(integration, installerOptions.installDir)}`, - ...(initiateLoginReason ? { initiateLoginReason } : {}), + corsOrigin: new URL(redirectUri).origin, + ...(!signInPath ? { initiateLoginReason: NO_SIGN_IN_ROUTE_REASON } : {}), }; + // Source patterns cannot prove that a client route is mounted and starts sign-in. + // Keep generating the route, but leave this dashboard setting for a browser check. + if (signInPath && clientOnly) { + delete setup.initiateLoginUri; + setup.initiateLoginReason = `Client-side ${signInPath} requires browser verification. Confirm it starts sign-in without a click, then set the Initiate login URI in the WorkOS dashboard. The existing setting was left unchanged.`; + } return reportAppUrlSetup(context.emitter, () => configureAuthkitApplication(setup, clientId, apiKey), { includeCors: true, }); diff --git a/src/lib/sign-in-route.ts b/src/lib/sign-in-route.ts index 5d6871b1..794c2c1b 100644 --- a/src/lib/sign-in-route.ts +++ b/src/lib/sign-in-route.ts @@ -1,7 +1,7 @@ import type { FrameworkConfig } from './framework-config.js'; import { getSignInPath } from './port-detection.js'; -/** Pin the sign-in route the installer saves as the Initiate login URI (Next.js has its own section). */ +/** Pin the intended Initiate login route (Next.js has its own section). */ export function buildSignInSection({ metadata, environment, @@ -12,13 +12,13 @@ export function buildSignInSection({ const lines = [ '## Sign-in route (Initiate login URI)', '', - `After you finish, the installer sets the WorkOS Initiate login URI to the app origin plus ${signInPath}. AuthKit sends users there when sign-in starts outside the app, such as from a password-reset email or an invitation. The route must exist at exactly ${signInPath}, must be public, and must start AuthKit sign-in through the SDK. It is never the callback route.`, + `The intended WorkOS Initiate login URI is the app origin plus ${signInPath}. AuthKit sends users there when sign-in starts outside the app, such as from a password-reset email or an invitation. The route must exist at exactly ${signInPath}, must be public, and must start AuthKit sign-in through the SDK. It is never the callback route.`, ]; // Client-only SDKs are the ones that need no API key. if (!environment.requiresApiKey) { lines.push( '', - `This is a client-only app, so add a ${signInPath} client route that calls the SDK's signIn() as soon as the AuthKit client is ready, without a click (for example, in a useEffect). AuthKit keeps password-reset and invitation details through this redirect, so signIn() needs no extra arguments. If the app has a client router, register the route there and keep it public. A React route such as } /> or { path: '${signInPath}', Component: Login } can use a direct useEffect(() => { if (!isLoading) void signIn(); }, [isLoading, signIn]) in Login; it does not need a second pathname guard. Define Login in the same module or use a relative default or named import from a client source file. Without a supported router binding, at startup (vanilla JS) or inside useEffect in a mounted component (React), use an explicit positive pathname guard: if (window.location.pathname === '${signInPath}') { void signIn(); }. Wait until AuthKit is ready; a readiness condition can be combined with the pathname guard using &&. Keep the call directly in that branch, not in a click handler or another function. The installer checks that this specific route starts sign-in before saving its URI; a route declaration alone is not enough. Keep the existing sign-in button. Do not add a server route.`, + `This is a client-only app, so add a ${signInPath} client route that calls the SDK's signIn() as soon as the AuthKit client is ready, without a click. Use the app's existing router and component conventions; without a router, handle this pathname at startup. AuthKit keeps password-reset and invitation details through this redirect, so signIn() needs no extra arguments. Keep the existing sign-in button. Do not add a server route. The installer leaves the Initiate login URI unchanged: ask the user to open ${signInPath} while signed out, confirm automatic sign-in in the browser, and then set this URI in the WorkOS dashboard.`, '', "The installer registers WORKOS_REDIRECT_URI as the app's Redirect URI. The SDK defaults to the page origin instead, so pass that value explicitly. The installer also writes it to .env.local under the bundler's env prefix (VITE_WORKOS_REDIRECT_URI for Vite, REACT_APP_WORKOS_REDIRECT_URI for Create React App), with the client ID beside it. Read that variable and set it as redirectUri on AuthKitProvider or createClient().", ); diff --git a/src/lib/validation/client-sign-in.spec.ts b/src/lib/validation/client-sign-in.spec.ts deleted file mode 100644 index b211c966..00000000 --- a/src/lib/validation/client-sign-in.spec.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { hasClientSignInBehavior } from './client-sign-in.js'; - -describe('mounted route component', () => { - it.each([ - 'function Login() { useEffect(() => { signIn(); }, []); return null; }', - 'function Login() { useEffect(() => { if (isLoading) return; signIn(); }, [isLoading]); return null; }', - 'function Login() { useEffect(() => { if (loading) { return; } void signIn(); }, [loading]); return null; }', - 'const Login = () => { React.useEffect(() => { if (!isLoading) signIn(); }, [isLoading]); return null; };', - 'const Login = function() { useEffect(() => { if (!loading) void authkit.signIn(); }, [loading]); return null; };', - ])('accepts supported component definitions and readiness guards: %s', (component) => { - expect(hasClientSignInBehavior(`${component} const routes = [{path: '/login', Component: Login}];`, '/login')).toBe( - true, - ); - }); - - it.each([ - 'function Login() { return ; }', - 'function Login() { function unused() { useEffect(() => signIn(), []); } return null; }', - 'function Login() { function unused() { signIn(); } useEffect(() => {}, []); return null; }', - 'function Login() { useEffect(() => { function unused() { signIn(); } }, []); return null; }', - 'function Login() { useEffect(() => { return; signIn(); }, []); return null; }', - 'function Login() { return null; useEffect(() => signIn(), []); }', - 'function Login() { useEffect(() => { if (false) signIn(); }, []); return null; }', - 'function Login() { useEffect(() => { /* signIn(); */ }, []); return null; }', - 'function Login() { return null; } function Unrelated() { useEffect(() => signIn(), []); }', - 'const Login = memo(() => { useEffect(() => signIn(), []); return null; });', - 'import Login from "./missing"; function Unrelated() { useEffect(() => signIn(), []); }', - ])('rejects a route component without a direct sign-in effect: %s', (component) => { - expect( - hasClientSignInBehavior(`${component} const routes = [{path: '/login', element: }];`, '/login'), - ).toBe(false); - }); - - it.each([ - 'function App(Login) { return } />; }', - 'function App({ Login }) { return } />; }', - 'function App() { const Login = () => null; return } />; }', - 'function App() { if (true) { var Login = () => null; } return } />; }', - 'try { fail(); } catch (Login) { const route = } />; }', - 'for (const Login of pages) { const route = } />; }', - 'const route = Login} />;', - 'const route = } />;', - 'const route = } {...overrides} />;', - "const route = {path: '/login', Component: Login, ...overrides};", - "const route = {path: '/login', element:

Login

, Component: Login};", - 'Login = () => null; const route = } />;', - '// } />', - ])('does not use an unrelated or shadowed Login binding: %s', (route) => { - expect( - hasClientSignInBehavior(`function Login() { useEffect(() => signIn(), []); return null; } ${route}`, '/login'), - ).toBe(false); - }); - - it('resolves a component declared in the route module’s local scope', () => { - expect( - hasClientSignInBehavior( - `function App() { - const Login = () => { useEffect(() => signIn(), []); return null; }; - return } />; - }`, - '/login', - ), - ).toBe(true); - }); - - it.each([ - '} />', - "const routes = [{ path: '/login', element: }];", - "const routes = [{ path: '/login', Component: Login }];", - "const routes = [{ path: '/login', component: Login }];", - ])('accepts the effect belonging to %s without a second pathname guard', (route) => { - expect( - hasClientSignInBehavior( - ` - function Login() { useEffect(() => signIn(), []); return null; } - ${route.startsWith('<') ? `function App() { return ${route}; }` : route} - `, - '/login', - ), - ).toBe(true); - }); -}); - -describe('sign-in belongs to the matching pathname branch', () => { - it.each([ - "if (window.location.pathname === '/login') signIn();", - "if ('/login' === location.pathname) { void client.signIn(); }", - "const path = window.location.pathname; if (path === '/login') { await authkit.signIn(); }", - "function App() { useEffect(() => { if (!isLoading && window.location.pathname === '/login') { void signIn(); } }, [isLoading, signIn]); return null; }", - "function Login() { React.useEffect(() => { if (window.location.pathname === '/login') { signIn(); } }, []); return Signing in; }", - '', - ])('accepts route-bound startup behavior: %s', (source) => { - expect(hasClientSignInBehavior(source, '/login')).toBe(true); - }); - - it.each([ - 'Hello} />; const button = ;', - "if (window.location.pathname === '/login') renderPage(); signIn();", - "if (window.location.pathname === '/login') renderPage(); else signIn();", - "if (window.location.pathname === '/logout') signIn();", - "if (window.location.pathname !== '/login') signIn();", - "if (window.location.pathname === '/login' || debug) signIn();", - "if (window.location.pathname === '/login' && false) signIn();", - "if (window.location.pathname === '/login') { button.onclick = () => signIn(); }", - "if (window.location.pathname === '/login') { function click() { signIn(); } }", - "if (window.location.pathname === '/login') { return; signIn(); }", - "function onClick() { if (window.location.pathname === '/login') signIn(); }", - "// if (window.location.pathname === '/login') signIn();", - `const example = "if (window.location.pathname === '/login') signIn();";`, - "const pathname = '/login'; if (window.location[pathname] === '/login') signIn();", - "not valid JavaScript if (window.location.pathname === '/login') signIn();", - ])('rejects unrelated, unreachable or click-only calls: %s', (source) => { - expect(hasClientSignInBehavior(source, '/login')).toBe(false); - }); - - it('requires startup behavior for a static login page too', () => { - expect(hasClientSignInBehavior('', '/login', true)).toBe(true); - expect(hasClientSignInBehavior('', '/login', true)).toBe( - false, - ); - expect(hasClientSignInBehavior('', '/login', true)).toBe(false); - }); -}); diff --git a/src/lib/validation/client-sign-in.ts b/src/lib/validation/client-sign-in.ts deleted file mode 100644 index cd05fcdb..00000000 --- a/src/lib/validation/client-sign-in.ts +++ /dev/null @@ -1,424 +0,0 @@ -import { parse } from '@babel/parser'; -import type { Node, Statement, Expression, MemberExpression, Program } from '@babel/types'; -import { posix } from 'node:path'; - -function propertyName(node: Node | null | undefined): string | undefined { - if (node?.type === 'Identifier') return node.name; - if (node?.type === 'StringLiteral') return node.value; - return undefined; -} - -function memberName(node: MemberExpression): string | undefined { - return node.computed && node.property.type !== 'StringLiteral' ? undefined : propertyName(node.property); -} - -function isPathname(node: Node): boolean { - if (node.type !== 'MemberExpression' || memberName(node) !== 'pathname') return false; - const location = node.object; - return ( - (location.type === 'Identifier' && location.name === 'location') || - (location.type === 'MemberExpression' && - memberName(location) === 'location' && - location.object.type === 'Identifier' && - location.object.name === 'window') - ); -} - -function isSignInCall(node: Node | null | undefined): boolean { - if (node?.type === 'AwaitExpression' || (node?.type === 'UnaryExpression' && node.operator === 'void')) { - return isSignInCall(node.argument); - } - return ( - node?.type === 'CallExpression' && - ((node.callee.type === 'Identifier' && node.callee.name === 'signIn') || - (node.callee.type === 'MemberExpression' && memberName(node.callee) === 'signIn')) - ); -} - -/** Only directly executed statements, never a button callback or a nested function. */ -function startsSignIn(statements: Statement[]): boolean { - for (const statement of statements) { - if (statement.type === 'ExpressionStatement' && isSignInCall(statement.expression)) return true; - if (statement.type === 'ReturnStatement') return isSignInCall(statement.argument); - if (statement.type === 'ThrowStatement') return false; - } - return false; -} - -function guardsPath(test: Expression, path: string, aliases: Set): boolean { - if (test.type === 'LogicalExpression' && test.operator === '&&') { - if ([test.left, test.right].some((part) => part.type === 'BooleanLiteral' && !part.value)) return false; - return guardsPath(test.left, path, aliases) || guardsPath(test.right, path, aliases); - } - if (test.type !== 'BinaryExpression' || !['===', '=='].includes(test.operator)) return false; - const match = (value: Node, pathname: Node) => - value.type === 'StringLiteral' && - value.value === path && - (isPathname(pathname) || (pathname.type === 'Identifier' && aliases.has(pathname.name))); - return match(test.left, test.right) || match(test.right, test.left); -} - -/** Route condition and sign-in must be in the same executed branch. */ -function guardedSignIn(statements: Statement[], path: string): boolean { - const aliases = new Set(); - for (const statement of statements) { - if (statement.type === 'VariableDeclaration' && statement.kind === 'const') { - for (const declaration of statement.declarations) { - if (declaration.id.type === 'Identifier' && declaration.init && isPathname(declaration.init)) { - aliases.add(declaration.id.name); - } - } - } - if (statement.type === 'IfStatement' && guardsPath(statement.test, path, aliases)) { - const body = statement.consequent.type === 'BlockStatement' ? statement.consequent.body : [statement.consequent]; - if (startsSignIn(body)) return true; - } - if (statement.type === 'ReturnStatement' || statement.type === 'ThrowStatement') return false; - } - return false; -} - -function isNode(value: unknown): value is Node { - return typeof value === 'object' && value !== null && 'type' in value && typeof value.type === 'string'; -} - -function effectStartsSignIn(node: Node, path: string): boolean { - if (node.type === 'CallExpression') { - const name = node.callee.type === 'MemberExpression' ? memberName(node.callee) : propertyName(node.callee); - const callback = node.arguments[0]; - if ( - name === 'useEffect' && - (callback?.type === 'ArrowFunctionExpression' || callback?.type === 'FunctionExpression') && - callback.body.type === 'BlockStatement' && - guardedSignIn(callback.body.body, path) - ) - return true; - } - return Object.values(node).some((value) => { - if (Array.isArray(value)) return value.some((child) => isNode(child) && effectStartsSignIn(child, path)); - return isNode(value) && effectStartsSignIn(value, path); - }); -} - -function children(node: Node): Node[] { - return Object.values(node).flatMap((value) => - Array.isArray(value) ? value.filter(isNode) : isNode(value) ? [value] : [], - ); -} - -type Bindings = Map; - -function bindPattern(pattern: Node, value: Node, bindings: Bindings): void { - if (pattern.type === 'Identifier') bindings.set(pattern.name, value); - else if (pattern.type === 'ObjectPattern') { - for (const property of pattern.properties) - bindPattern(property.type === 'RestElement' ? property.argument : property.value, value, bindings); - } else if (pattern.type === 'ArrayPattern') { - for (const element of pattern.elements) if (element) bindPattern(element, value, bindings); - } else if (pattern.type === 'AssignmentPattern') bindPattern(pattern.left, value, bindings); - else if (pattern.type === 'RestElement') bindPattern(pattern.argument, value, bindings); -} - -function scopeBindings(statements: Node[]): Bindings { - const bindings: Bindings = new Map(); - for (let statement of statements) { - if (statement.type === 'ExportNamedDeclaration' || statement.type === 'ExportDefaultDeclaration') { - if (!statement.declaration) continue; - statement = statement.declaration; - } - if ((statement.type === 'FunctionDeclaration' || statement.type === 'ClassDeclaration') && statement.id) { - bindings.set(statement.id.name, statement); - } else if (statement.type === 'VariableDeclaration') { - for (const declaration of statement.declarations) { - bindPattern( - declaration.id, - statement.kind === 'const' && declaration.id.type === 'Identifier' && declaration.init - ? declaration.init - : declaration, - bindings, - ); - } - } else if (statement.type === 'ImportDeclaration') { - for (const specifier of statement.specifiers) bindings.set(specifier.local.name, specifier); - } - } - return bindings; -} - -function isFunction( - node: Node, -): node is Extract { - return ( - node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression' - ); -} - -/** Only a direct effect in this component, never a nested helper or event callback. */ -function componentStartsSignIn(node: Node): boolean { - if (!isFunction(node) || node.body.type !== 'BlockStatement') return false; - for (const statement of node.body.body) { - if (statement.type === 'ReturnStatement' || statement.type === 'ThrowStatement') return false; - if (statement.type !== 'ExpressionStatement' || statement.expression.type !== 'CallExpression') continue; - const call = statement.expression; - const name = call.callee.type === 'MemberExpression' ? memberName(call.callee) : propertyName(call.callee); - const callback = call.arguments[0]; - if (name !== 'useEffect' || !callback || !isFunction(callback)) continue; - if (callback.body.type !== 'BlockStatement') { - if (isSignInCall(callback.body)) return true; - } else if (effectBodyStartsSignIn(callback.body.body)) return true; - } - return false; -} - -/** Readiness guards are supported, but arbitrary conditions are not route evidence. */ -function effectBodyStartsSignIn(statements: Statement[]): boolean { - for (const statement of statements) { - if (statement.type === 'ExpressionStatement' && isSignInCall(statement.expression)) return true; - if (statement.type === 'ReturnStatement') return isSignInCall(statement.argument); - if (statement.type === 'ThrowStatement') return false; - if (statement.type === 'IfStatement') { - const test = statement.test; - const body = statement.consequent.type === 'BlockStatement' ? statement.consequent.body : [statement.consequent]; - // Common SDK-ready form: if (isLoading) return; signIn(); - if ( - test.type === 'Identifier' && - ['isLoading', 'loading'].includes(test.name) && - !statement.alternate && - body.length === 1 && - body[0].type === 'ReturnStatement' && - !body[0].argument - ) - continue; - if ( - test.type !== 'UnaryExpression' || - test.operator !== '!' || - test.argument.type !== 'Identifier' || - !['isLoading', 'loading'].includes(test.argument.name) - ) - return false; - if (startsSignIn(body)) return true; - return false; - } - } - return false; -} - -function routeComponent(node: Node, path: string): string | undefined { - const values = new Map(); - if ( - node.type === 'JSXElement' && - node.openingElement.name.type === 'JSXIdentifier' && - node.openingElement.name.name === 'Route' - ) { - for (const attribute of node.openingElement.attributes) { - if (attribute.type !== 'JSXAttribute' || attribute.name.type !== 'JSXIdentifier' || !attribute.value) - return undefined; - if (values.has(attribute.name.name)) return undefined; - values.set( - attribute.name.name, - attribute.value.type === 'JSXExpressionContainer' ? attribute.value.expression : attribute.value, - ); - } - } else if (node.type === 'ObjectExpression') { - for (const property of node.properties) { - if (property.type !== 'ObjectProperty' || property.computed) return undefined; - const name = propertyName(property.key); - if (!name || values.has(name)) return undefined; - values.set(name, property.value); - } - } else return undefined; - const routePath = values.get('path'); - if (routePath?.type !== 'StringLiteral' || routePath.value !== path) return undefined; - const targets = ['element', 'Component', 'component'].filter((name) => values.has(name)); - if (targets.length !== 1) return undefined; - const target = values.get(targets[0])!; - if (targets[0] !== 'element') return target.type === 'Identifier' ? target.name : undefined; - if (target.type !== 'JSXElement' || target.openingElement.name.type !== 'JSXIdentifier') return undefined; - const name = target.openingElement.name.name; - return /^[A-Z]/.test(name) ? name : undefined; -} - -/** Conservatively decline reassigned names, including writes in nested scopes. */ -function reassignedNames(node: Node, names: Bindings = new Map()): Bindings { - if (node.type === 'AssignmentExpression') bindPattern(node.left, node, names); - if (node.type === 'UpdateExpression') bindPattern(node.argument, node, names); - for (const child of children(node)) reassignedNames(child, names); - return names; -} - -function exportedComponent(program: Program, name: string): Node | undefined { - const bindings = scopeBindings(program.body); - const reassigned = reassignedNames(program); - for (const local of reassigned.keys()) bindings.delete(local); - for (const statement of program.body) { - if (name === 'default' && statement.type === 'ExportDefaultDeclaration') { - const declaration = statement.declaration; - if (declaration.type === 'Identifier') return bindings.get(declaration.name); - if (declaration.type === 'FunctionDeclaration' && declaration.id && reassigned.has(declaration.id.name)) - return undefined; - return declaration; - } - if (statement.type !== 'ExportNamedDeclaration' || statement.source || statement.exportKind === 'type') continue; - if (statement.declaration) { - if (scopeBindings([statement.declaration]).has(name)) return bindings.get(name); - } - for (const specifier of statement.specifiers) { - if ( - specifier.type === 'ExportSpecifier' && - specifier.exportKind !== 'type' && - propertyName(specifier.exported) === name - ) - return bindings.get(specifier.local.name); - } - } - return undefined; -} - -function importedComponent( - binding: Node, - program: Program, - file: string, - modules: Map, -): Node | undefined { - if (binding.type !== 'ImportSpecifier' && binding.type !== 'ImportDefaultSpecifier') return undefined; - const declaration = program.body.find( - (node) => node.type === 'ImportDeclaration' && node.specifiers.includes(binding), - ); - if ( - declaration?.type !== 'ImportDeclaration' || - declaration.importKind === 'type' || - (binding.type === 'ImportSpecifier' && binding.importKind === 'type') - ) - return undefined; - const source = declaration.source.value; - if (!source.startsWith('./') && !source.startsWith('../')) return undefined; - const base = posix.normalize(posix.join(posix.dirname(file), source)); - const extensions = ['.tsx', '.ts', '.jsx', '.js', '.mjs']; - const candidates = [ - base, - ...extensions.map((ext) => base + ext), - ...extensions.map((ext) => `${base}/index${ext}`), - ...(/\.jsx?$/.test(base) ? [base.replace(/\.jsx?$/, '.ts'), base.replace(/\.jsx?$/, '.tsx')] : []), - ].filter((candidate) => modules.has(candidate)); - if (candidates.length !== 1) return undefined; - const programs = modules.get(candidates[0])!; - if (programs.length !== 1) return undefined; - return exportedComponent( - programs[0], - binding.type === 'ImportDefaultSpecifier' ? 'default' : propertyName(binding.imported)!, - ); -} - -/** Hoisted var bindings must also shadow outer components, even inside a nested block. */ -function hoistedVars(node: Node, bindings: Bindings): void { - for (const child of children(node)) { - if (isFunction(child)) continue; - if (child.type === 'VariableDeclaration' && child.kind === 'var') { - for (const declaration of child.declarations) bindPattern(declaration.id, declaration, bindings); - } - hoistedVars(child, bindings); - } -} - -/** - * Tie a literal route target to its lexical component binding and direct effect. - * This does not prove the router is mounted at runtime. Wrappers, re-exports, - * namespace imports and computed route targets deliberately remain unsupported. - */ -function mountedRouteStartsSignIn( - program: Program, - path: string, - file: string, - modules: Map, -): boolean { - const reassigned = reassignedNames(program); - function visit(node: Node, scopes: Bindings[]): boolean { - // These scopes/forms are not supported; do not resolve through them by name. - if ( - node.type === 'ClassDeclaration' || - node.type === 'ClassExpression' || - node.type === 'ObjectMethod' || - node.type === 'TSModuleDeclaration' - ) - return false; - if (node.type === 'Program' || node.type === 'BlockStatement') scopes = [...scopes, scopeBindings(node.body)]; - if (isFunction(node)) { - const parameters: Bindings = new Map(); - if (node.type === 'FunctionExpression' && node.id) parameters.set(node.id.name, node); - for (const parameter of node.params) bindPattern(parameter, parameter, parameters); - hoistedVars(node.body, parameters); - scopes = [...scopes, parameters]; - } - if (node.type === 'CatchClause' && node.param) { - const bindings: Bindings = new Map(); - bindPattern(node.param, node.param, bindings); - scopes = [...scopes, bindings]; - } - if (node.type === 'ForStatement' && node.init?.type === 'VariableDeclaration') - scopes = [...scopes, scopeBindings([node.init])]; - if ((node.type === 'ForInStatement' || node.type === 'ForOfStatement') && node.left.type === 'VariableDeclaration') - scopes = [...scopes, scopeBindings([node.left])]; - if (node.type === 'SwitchStatement') - scopes = [...scopes, scopeBindings(node.cases.flatMap((branch) => branch.consequent))]; - const name = routeComponent(node, path); - if (name && !reassigned.has(name)) { - const binding = [...scopes] - .reverse() - .find((scope) => scope.has(name)) - ?.get(name); - if (binding && componentStartsSignIn(importedComponent(binding, program, file, modules) ?? binding)) return true; - } - return children(node).some((child) => visit(child, scopes)); - } - return visit(program, []); -} - -export interface ClientSource { - file: string; - content: string; -} - -function parsePrograms(source: string): Program[] { - const scripts = /]*)>([\s\S]*?)<\/script\s*>/gi)] - .filter(([, attributes]) => !/\bsrc\s*=/i.test(attributes)) - .map(([, , body]) => body) - : [source]; - return scripts.flatMap((script) => { - try { - return [ - parse(script, { - sourceType: 'unambiguous', - plugins: ['typescript', 'jsx'], - allowReturnOutsideFunction: true, - }).program, - ]; - } catch { - // Unparseable or unsupported source must not authorize a dashboard write. - return []; - } - }); -} - -/** Source evidence only; browser-flow testing remains a separate step. */ -export function hasClientSignInBehavior(source: string, path: string, staticPage = false): boolean { - return hasClientSignInBehaviorInSources([{ file: '', content: source }], path, staticPage); -} - -/** Imports resolve only against these already bounded records, never additional disk reads. */ -export function hasClientSignInBehaviorInSources(sources: ClientSource[], path: string, staticPage = false): boolean { - const modules = new Map(sources.map(({ file, content }) => [file, parsePrograms(content)])); - for (const [file, programs] of modules) { - for (const program of programs) { - if ( - staticPage - ? startsSignIn(program.body) - : guardedSignIn(program.body, path) || - effectStartsSignIn(program, path) || - mountedRouteStartsSignIn(program, path, file, modules) - ) - return true; - } - } - return false; -} diff --git a/src/lib/validation/client-source.spec.ts b/src/lib/validation/client-source.spec.ts index e52b481b..f19a2a95 100644 --- a/src/lib/validation/client-source.spec.ts +++ b/src/lib/validation/client-source.spec.ts @@ -2,18 +2,26 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; -import { hasClientSignInRoute, validateInstallation } from './validator.js'; +import { validateInstallation } from './validator.js'; let project: string; async function file(path: string, content: string) { await mkdir(dirname(join(project, path)), { recursive: true }); await writeFile(join(project, path), content); } -const login = "if (window.location.pathname === '/login') authkit.signIn();"; const client = 'createClient(id, { redirectUri: import.meta.env.VITE_WORKOS_REDIRECT_URI });'; beforeEach(async () => { project = await mkdtemp(join(tmpdir(), 'client-source-')); - await file('package.json', JSON.stringify({ devDependencies: { vite: '^6.0.0' } })); + await file( + 'package.json', + JSON.stringify({ + dependencies: { '@workos-inc/authkit-js': '^1.0.0' }, + devDependencies: { vite: '^6.0.0' }, + }), + ); + await file('.env.local', 'WORKOS_CLIENT_ID=client_test\n'); + await file('index.html', ''); + await file('src/main.ts', client); }); afterEach(async () => { await rm(project, { recursive: true, force: true }); @@ -39,176 +47,63 @@ it.each([ 'api/auth.ts', 'functions/auth.ts', 'src/auth.server.ts', -])('does not let server-only %s satisfy browser route or callback checks', async (path) => { + 'packages/another-app/src/main.ts', +])('does not let %s satisfy browser callback checks or cause env-read errors', async (path) => { await file('src/main.ts', 'const app = {};'); - await file(path, `${login}\nconst redirectUri = process.env.WORKOS_REDIRECT_URI;`); + await file(path, 'const redirectUri = process.env.WORKOS_REDIRECT_URI;'); const result = await validateInstallation('vanilla-js', project, { runBuild: false }); - expect(await hasClientSignInRoute(project, '/login')).toBe(false); - expect(result.issues.some((issue) => issue.message.includes('/login route'))).toBe(true); expect(result.issues.some((issue) => issue.message === 'The AuthKit client does not set redirectUri')).toBe(true); expect(result.issues.some((issue) => issue.message.startsWith('The client reads'))).toBe(false); }); it.each([ - "if (redirectTarget === '/login') authkit.signIn();", - "if ('/login' === redirectTarget) authkit.signIn();", - "switch(window.location.pathname) {case '/': home();} switch(action) {case '/login': authkit.signIn();}", - "const links = [{ path: '/login', label: 'Sign in' }]; authkit.signIn();", - "const path = '/login'; authkit.signIn();", - 'Hello} />', -])('does not mistake unrelated code for a sign-in route: %s', async (source) => { - await file('src/main.tsx', source); - expect(await hasClientSignInRoute(project, '/login')).toBe(false); -}); - -it.each([ - login, - "const current = window.location.pathname; if(current === '/login') authkit.signIn();", - "if ('/login' === location.pathname) authkit.signIn();", - "function Login() { useEffect(() => { if (window.location.pathname === '/login') signIn(); }, []); return null; }", - "function App() { useEffect(() => { if (!loading && window.location.pathname === '/login') void signIn(); }, [loading]); return null; }", -])('accepts supported route evidence and a sign-in call: %s', async (source) => { - await file('src/auth.config.tsx', source); - expect(await hasClientSignInRoute(project, '/login')).toBe(true); + "import Login from '@pages/Login'; export const routes = [{ path: '/login', Component: memo(Login) }];", + 'function Login() { useStartSignIn(); return null; }', + "switch (location.pathname) { case '/login': authkit.signIn(); }", + '// No login route yet', +])('requests browser verification without rejecting client route syntax: %s', async (source) => { + await file('src/login.tsx', source); + const result = await validateInstallation('vanilla-js', project, { runBuild: false }); + expect(result.passed).toBe(true); + expect(result.issues).toContainEqual( + expect.objectContaining({ + severity: 'warning', + message: 'Client-side /login route requires browser verification', + hint: expect.stringContaining('the installer leaves that setting unchanged'), + }), + ); }); -it('does not need to resolve inline imports or custom aliases to inspect conventional client files', async () => { +it('checks callback configuration without resolving inline imports or custom aliases', async () => { await file('index.html', ''); - await file('src/main.ts', "import '@features/login';"); - await file('src/features/login.ts', `${login}\n${client}`); - expect(await hasClientSignInRoute(project, '/login')).toBe(true); + await file('src/main.ts', "import '@features/auth';"); + await file('src/features/auth.ts', client); const result = await validateInstallation('vanilla-js', project, { runBuild: false }); - expect(result.issues.some((issue) => issue.message.includes('/login route'))).toBe(false); + expect(result.passed).toBe(true); expect(result.issues.some((issue) => issue.message === 'The AuthKit client does not set redirectUri')).toBe(false); }); -it('does not let a sign-in button in a different file qualify the login route', async () => { - await file('src/routes.tsx', 'Hello} />'); - await file('src/button.tsx', 'export const button = ;'); - expect(await hasClientSignInRoute(project, '/login')).toBe(false); - const result = await validateInstallation('react', project, { runBuild: false }); - expect(result.issues.some((issue) => issue.message.includes('/login route'))).toBe(true); -}); - -it('does not scan other workspaces as this app', async () => { - await file('src/main.ts', client); - await file('packages/another-app/src/main.ts', login); - expect(await hasClientSignInRoute(project, '/login')).toBe(false); -}); - -it.each([ - ['import Login from "./Login";', 'export default function Login()'], - ['import { Login } from "./Login";', 'export function Login()'], - ['import { Entry as Login } from "./Login";', 'export function Entry()'], - ['import Login from "./Login.tsx";', 'export default () =>'], - ['import Login from "./Login.js";', 'export default function Login()'], - ['import Login from "./Login.jsx";', 'export default function Login()'], - ['import { Login } from "./Login";', 'export const Login = () =>'], - ['import Login from "./Login";', 'const Entry = function()'], -])('resolves a split-file mounted login component: %s', async (importLine, declaration) => { - await file( - 'src/App.tsx', - `${importLine} export default function App() { - return } />; - }`, - ); - await file( - 'src/Login.tsx', - `${declaration} { - const { signIn, isLoading } = useAuth(); - useEffect(() => { if (!isLoading) void signIn(); }, [isLoading, signIn]); - return Signing in; - } ${declaration.startsWith('const') ? '; export { Entry as default };' : ''}`, - ); - expect(await hasClientSignInRoute(project, '/login')).toBe(true); - const result = await validateInstallation('react', project, { runBuild: false }); - expect(result.issues.some((issue) => issue.message.includes('/login route'))).toBe(false); -}); - -it.each([ - ['import Login from "./Missing";', 'export default function Login() { useEffect(() => signIn(), []); return null; }'], - [ - 'import Login from "@pages/Login";', - 'export default function Login() { useEffect(() => signIn(), []); return null; }', - ], - ['import Login from "./Login";', 'export function Login() { useEffect(() => signIn(), []); return null; }'], - [ - 'import { Missing as Login } from "./Login";', - 'export function Login() { useEffect(() => signIn(), []); return null; }', - ], - ['import { Login } from "./Login";', 'function Login() { useEffect(() => signIn(), []); return null; }'], - [ - 'import Login from "./Login";', - 'export default function Page() { return

Login

; } function Login() { useEffect(() => signIn(), []); return null; }', - ], - ['import Login from "./Login";', 'export { default } from "./Unrelated";'], - ['import type { Login } from "./Login";', 'export function Login() { useEffect(() => signIn(), []); return null; }'], -])('fails closed for unresolved or wrong component exports: %s %s', async (importLine, component) => { - await file( - 'src/App.tsx', - `${importLine} export default function App() { return } />; }`, - ); - await file('src/Login.tsx', component); - await file('src/Unrelated.tsx', 'export default function Login() { useEffect(() => signIn(), []); return null; }'); - expect(await hasClientSignInRoute(project, '/login')).toBe(false); - const result = await validateInstallation('react', project, { runBuild: false }); - expect(result.issues.some((issue) => issue.message.includes('/login route'))).toBe(true); -}); - -it.each(['element: ', 'Component: Login', 'component: Login'])( - 'resolves imported object route %s via a directory index', - async (target) => { - await file( - 'src/App.tsx', - `import { Entry as Login } from './pages'; const routes = [{path: '/login', ${target}}];`, - ); - await file( - 'src/pages/index.tsx', - 'const Page = () => { useEffect(() => signIn(), []); return null; }; export { Page as Entry };', - ); - expect(await hasClientSignInRoute(project, '/login')).toBe(true); - }, -); - -it('declines ambiguous relative module resolution', async () => { - await file('src/App.tsx', 'import Login from "./Login"; const routes = [{path: "/login", Component: Login}];'); - await file('src/Login.tsx', 'export default function Login() { useEffect(() => signIn(), []); return null; }'); - await file('src/Login/index.tsx', 'export default function Login() { return null; }'); - expect(await hasClientSignInRoute(project, '/login')).toBe(false); -}); - -it('does not expand the bounded scan to resolve imports', async () => { - await file( - 'src/App.tsx', - 'import Login from "../packages/Login"; const routes = [{path: "/login", Component: Login}];', - ); - await file('packages/Login.tsx', 'export default function Login() { useEffect(() => signIn(), []); return null; }'); - expect(await hasClientSignInRoute(project, '/login')).toBe(false); -}); - describe('bounded scan', () => { async function incomplete() { - expect(await hasClientSignInRoute(project, '/login')).toBe(false); const result = await validateInstallation('vanilla-js', project, { runBuild: false }); - expect(result.passed).toBe(false); + expect(result.passed).toBe(true); expect(result.issues).toContainEqual( expect.objectContaining({ - severity: 'error', - message: 'Client source checks were incomplete; automatic sign-in URL setup will be skipped', + severity: 'warning', + message: 'Client source checks were incomplete; callback configuration could not be fully checked', }), ); + expect(result.issues.some((issue) => issue.message === 'The AuthKit client does not set redirectUri')).toBe(false); } - it('does not authorize a route from a truncated file set', async () => { - await file('src/login.ts', login); + it('reports an incomplete file set without failing installation', async () => { await Promise.all(Array.from({ length: 256 }, (_, i) => file(`src/file-${i}.ts`, 'export {};'))); await incomplete(); }); - it('bounds individual file reads', async () => { - await file('src/main.ts', login + ' '.repeat(256 * 1024)); + it('bounds individual file reads without claiming the callback is missing', async () => { + await file('src/main.ts', ' '.repeat(256 * 1024 + 1)); await incomplete(); }); it('bounds aggregate content, not only individual files', async () => { - await file('src/login.ts', login); await Promise.all(Array.from({ length: 17 }, (_, i) => file(`src/file-${i}.ts`, ' '.repeat(256 * 1024)))); await incomplete(); }); diff --git a/src/lib/validation/validator.spec.ts b/src/lib/validation/validator.spec.ts index 26031b76..a27922f5 100644 --- a/src/lib/validation/validator.spec.ts +++ b/src/lib/validation/validator.spec.ts @@ -749,23 +749,23 @@ describe('validateInstallation', () => { }); }); - describe('client-only sign-in route validation', () => { + describe('client-only configuration checks', () => { const signInIssue = (issues: { message: string }[]) => issues.find((i) => i.message.includes('/login route')); const redirectIssue = (issues: { message: string }[]) => issues.find((i) => i.message.includes('redirectUri')); const envReadIssue = (issues: { message: string }[]) => issues.find((i) => i.message.startsWith('The client reads')); - it('fails a React SPA without a /login route', async () => { + it('requests browser verification rather than declaring a React route missing', async () => { mkdirSync(join(testDir, 'src'), { recursive: true }); writeFileSync(join(testDir, 'src', 'App.tsx'), 'const { signIn } = useAuth();'); const result = await validateInstallation('react', testDir, { runBuild: false }); - expect(signInIssue(result.issues)?.severity).toBe('error'); - expect(result.passed).toBe(false); + expect(signInIssue(result.issues)?.severity).toBe('warning'); + expect(signInIssue(result.issues)?.message).toContain('requires browser verification'); }); - it('accepts a React SPA whose source serves /login', async () => { + it('does not claim to verify a React route from source patterns', async () => { mkdirSync(join(testDir, 'src'), { recursive: true }); writeFileSync(join(testDir, 'src', 'App.tsx'), '} />'); writeFileSync( @@ -775,16 +775,16 @@ describe('validateInstallation', () => { const result = await validateInstallation('react', testDir, { runBuild: false }); - expect(signInIssue(result.issues)).toBeUndefined(); + expect(signInIssue(result.issues)?.severity).toBe('warning'); }); - it('accepts a vanilla JS app with a sign-in page', async () => { + it('requests browser verification even for a static sign-in page', async () => { mkdirSync(join(testDir, 'login'), { recursive: true }); writeFileSync(join(testDir, 'login', 'index.html'), ''); const result = await validateInstallation('vanilla-js', testDir, { runBuild: false }); - expect(signInIssue(result.issues)).toBeUndefined(); + expect(signInIssue(result.issues)?.severity).toBe('warning'); }); it('ignores /login mentions inside node_modules', async () => { @@ -799,13 +799,13 @@ describe('validateInstallation', () => { it.each([ "const path = window.location.pathname;\nif (path === '/login') signIn();", "if ('/login' === location.pathname) signIn();", - ])('accepts a pathname check through any variable: %s', async (code) => { + ])('leaves pathname behavior to browser verification: %s', async (code) => { mkdirSync(join(testDir, 'src'), { recursive: true }); writeFileSync(join(testDir, 'src', 'main.tsx'), code); const result = await validateInstallation('react', testDir, { runBuild: false }); - expect(signInIssue(result.issues)).toBeUndefined(); + expect(signInIssue(result.issues)?.severity).toBe('warning'); }); it('does not treat a link to /login as the route', async () => { @@ -814,7 +814,7 @@ describe('validateInstallation', () => { const result = await validateInstallation('react', testDir, { runBuild: false }); - expect(signInIssue(result.issues)?.severity).toBe('error'); + expect(signInIssue(result.issues)?.severity).toBe('warning'); }); it('reads AuthKit setup from a config file inside src/', async () => { @@ -827,7 +827,7 @@ describe('validateInstallation', () => { const result = await validateInstallation('react', testDir, { runBuild: false }); - expect(signInIssue(result.issues)).toBeUndefined(); + expect(signInIssue(result.issues)?.severity).toBe('warning'); expect(redirectIssue(result.issues)).toBeUndefined(); }); diff --git a/src/lib/validation/validator.ts b/src/lib/validation/validator.ts index 274ce61e..1a80d2d4 100644 --- a/src/lib/validation/validator.ts +++ b/src/lib/validation/validator.ts @@ -4,7 +4,6 @@ import { join } from 'path'; import fg from 'fast-glob'; import type { ValidationResult, ValidationRules, ValidationIssue } from './types.js'; import { runBuildValidation } from './build-validator.js'; -import { hasClientSignInBehavior, hasClientSignInBehaviorInSources, type ClientSource } from './client-sign-in.js'; import { detectPort, getClientEnvPrefix, getSignInPath } from '../port-detection.js'; import { nextjsRoutePath, findNextjsSignInPage } from '../../integrations/nextjs/utils.js'; import nextjsRules from './rules/nextjs.json' with { type: 'json' }; @@ -277,33 +276,26 @@ export async function validateFrameworkSpecific(framework: string, projectDir: s return issues; } -/** - * Client-only apps have no route files to match, so check their source for - * the sign-in route (saved as the Initiate login URI) and for `redirectUri`: - * the SDKs default to the page origin, but the installer registers - * WORKOS_REDIRECT_URI and writes it under the bundler's env prefix. - */ +/** Check callback configuration, not whether arbitrary client routes execute. */ async function validateClientOnlyApp(framework: string, projectDir: string, issues: ValidationIssue[]) { const signInPath = getSignInPath(framework); const prefix = getClientEnvPrefix(projectDir); - const { sources, complete } = await readClientSource(projectDir, prefix); - if (!complete) { + if (signInPath) issues.push({ type: 'file', - severity: 'error', - message: 'Client source checks were incomplete; automatic sign-in URL setup will be skipped', - hint: 'The check is limited to 256 source files, 256 KiB per file, and 4 MiB total. Check the sign-in route and callback manually.', + severity: 'warning', + message: `Client-side ${signInPath} route requires browser verification`, + hint: `Open ${signInPath} while signed out and confirm it starts AuthKit sign-in without a click. Then set the app origin plus ${signInPath} as the Initiate login URI in the WorkOS dashboard; the installer leaves that setting unchanged.`, }); - return; - } - if (signInPath && !(await servesSignInRoute(projectDir, sources, signInPath))) + const { sources, complete } = await readClientSource(projectDir, prefix); + if (!complete) issues.push({ type: 'file', - severity: 'error', - message: `No ${signInPath} route starts sign-in`, - hint: `Call signIn() once AuthKit is ready in the mounted ${signInPath} route component's useEffect, or at startup/in an effect inside an if (window.location.pathname === '${signInPath}') branch. A route declaration or unrelated sign-in button is not enough.`, + severity: 'warning', + message: 'Client source checks were incomplete; callback configuration could not be fully checked', + hint: 'The check is limited to 256 source files, 256 KiB per file, and 4 MiB total. Check that the SDK uses the installed redirect URI in the browser.', }); - if (!sources.some(({ content }) => content.includes('redirectUri'))) + if (complete && !sources.some((content) => content.includes('redirectUri'))) issues.push({ type: 'pattern', severity: 'error', @@ -313,7 +305,7 @@ async function validateClientOnlyApp(framework: string, projectDir: string, issu if (!prefix) return; const expected = BROWSER_REDIRECT_ENV[prefix]; const reads = new Set( - sources.flatMap(({ content }) => [...content.matchAll(REDIRECT_ENV_REFERENCE)].map(([read]) => read)), + sources.flatMap((content) => [...content.matchAll(REDIRECT_ENV_REFERENCE)].map(([read]) => read)), ); reads.delete(expected); for (const read of reads) @@ -334,27 +326,6 @@ const BROWSER_REDIRECT_ENV = { /** A redirect URI env read, e.g. import.meta.env.VITE_WORKOS_REDIRECT_URI. */ const REDIRECT_ENV_REFERENCE = /(?:import\.meta\.env|process\.env)\.\w*WORKOS_REDIRECT_URI\b/g; -/** Whether the supported client source forms provide evidence of a sign-in route. */ -export async function hasClientSignInRoute(projectDir: string, signInPath: string): Promise { - const { sources, complete } = await readClientSource(projectDir, getClientEnvPrefix(projectDir)); - return complete && servesSignInRoute(projectDir, sources, signInPath); -} - -async function servesSignInRoute(projectDir: string, sources: ClientSource[], signInPath: string): Promise { - if (hasClientSignInBehaviorInSources(sources, signInPath)) return true; - // A static page at the path, e.g. login/index.html, that starts sign-in. - const segment = signInPath.replace(/^\/|\/$/g, ''); - const pages = await fg( - [`${segment}.html`, `${segment}/index.html`, `public/${segment}.html`, `public/${segment}/index.html`], - { cwd: projectDir }, - ); - for (const page of pages) { - const content = await readBoundedSource(join(projectDir, page), MAX_SOURCE_BYTES); - if (content !== undefined && hasClientSignInBehavior(content, signInPath, true)) return true; - } - return false; -} - const MAX_SOURCE_FILES = 256; const MAX_SOURCE_BYTES = 256 * 1024; const MAX_SCAN_BYTES = 4 * 1024 * 1024; @@ -381,16 +352,15 @@ async function readBoundedSource(path: string, limit: number): Promise, -): Promise<{ sources: ClientSource[]; complete: boolean }> { +): Promise<{ sources: string[]; complete: boolean }> { const extension = '*.{ts,tsx,js,jsx,mjs,html,htm}'; const files = fg.stream( prefix === 'REACT_APP_' ? [`src/**/${extension}`] : [extension, `{src,app,client}/**/${extension}`], @@ -407,7 +377,7 @@ async function readClientSource( ], }, ); - const sources: ClientSource[] = []; + const sources: string[] = []; let remaining = MAX_SCAN_BYTES; try { for await (const file of files) { @@ -415,7 +385,7 @@ async function readClientSource( const content = await readBoundedSource(join(projectDir, String(file)), Math.min(MAX_SOURCE_BYTES, remaining)); if (content === undefined) return { sources, complete: false }; remaining -= Buffer.byteLength(content); - sources.push({ file: String(file).replaceAll('\\', '/'), content }); + sources.push(content); } } catch { return { sources, complete: false }; From 0b21bd6a333eb5c907abe76d454cdc1ccf210774 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Fri, 25 Sep 2026 05:11:24 -0500 Subject: [PATCH 34/34] ci: rerun checks after retargeting installer fixes to main