diff --git a/README.md b/README.md index 3c6ae363..e2e2932c 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. 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/src/cli.config.ts b/src/cli.config.ts index c40f00e0..03e17cb3 100644 --- a/src/cli.config.ts +++ b/src/cli.config.ts @@ -39,54 +39,77 @@ export const config = { nextjs: { port: 3000, callbackPath: '/auth/callback', + signInPath: '/sign-in', }, react: { port: 5173, callbackPath: '/callback', + 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: '/login', }, python: { port: 8000, callbackPath: '/auth/callback/', + signInPath: '/auth/login/', }, ruby: { port: 3000, callbackPath: '/auth/callback', + signInPath: '/auth/login', }, php: { port: 8000, callbackPath: '/auth/callback', + signInPath: '/login.php', }, 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', + }, + sveltekit: { + port: 5173, + callbackPath: '/callback', + signInPath: '/sign-in', + }, + node: { + port: 3000, + callbackPath: '/auth/callback', + signInPath: '/login', }, kotlin: { port: 8080, callbackPath: '/auth/callback', + signInPath: '/auth/login', }, }, diff --git a/src/integrations/dotnet/index.ts b/src/integrations/dotnet/index.ts index 4f1bf5d4..8faeb7fa 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)}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..2404a49e 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)}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..d821a894 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)}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..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 (/auth/login, /auth/callback, /auth/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 033c2010..30c1919e 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)}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 27f1fa06..2c6725c6 100644 --- a/src/lib/agent-runner.spec.ts +++ b/src/lib/agent-runner.spec.ts @@ -107,6 +107,54 @@ describe('installer prompt', () => { } }); + const promptFor = async (integration: FrameworkConfig['metadata']['integration'], skillName: string) => { + 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]; + }; + + 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 /login'); + expect(prompt).toContain('add a /login client route'); + 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'); + }); + + 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.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)'); + }); + 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..6e29d2f6 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 } from './port-detection.js'; +import { detectPort, resolveRedirectUri } from './port-detection.js'; +import { buildSignInSection } from './sign-in-route.js'; import { writeEnvLocal } from './env-writer.js'; /** @@ -85,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 = @@ -321,6 +320,7 @@ async function buildIntegrationPrompt( 'WORKOS_COOKIE_PASSWORD', ]; const envVarList = envVars.map((v) => `- ${v}`).join('\n'); + const signInSection = buildSignInSection(config); return `You are integrating WorkOS AuthKit into this ${config.metadata.name} application. @@ -355,7 +355,7 @@ 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.`; } diff --git a/src/lib/authkit-application-setup.spec.ts b/src/lib/authkit-application-setup.spec.ts index 6469a56d..ea22acb9 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,10 +19,17 @@ 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'; -import { configureAuthkitApplication, readNextjsApplicationSetup } from './authkit-application-setup.js'; +import { + buildApplicationSetup, + 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 = { @@ -35,6 +46,7 @@ let application: { logoutUris: { id?: string; uri: string; isDefault: boolean }[]; initiateLoginUri: string | null; appHomepageUrl?: string; + webOrigins: { origin: string }[]; }; const writes = () => vi @@ -46,12 +58,20 @@ 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 }, ]); 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 }], @@ -60,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; @@ -98,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 })); @@ -186,15 +359,90 @@ 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; + application.redirectUris = []; + delete application.appHomepageUrl; + 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'); + 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 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'); + 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 () => { delete application.appHomepageUrl; expect((await configureAuthkitApplication(setup, setup.clientId)).verified).toBe(true); 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( @@ -210,44 +458,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(); }, ); @@ -354,7 +630,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' }); } @@ -582,4 +858,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..a418165a 100644 --- a/src/lib/authkit-application-setup.ts +++ b/src/lib/authkit-application-setup.ts @@ -6,14 +6,22 @@ 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 { createCorsOrigin, isUnclaimedEnvironmentKey, setHomepageUrl } from './workos-management.js'; +import { getSignInPath } from './port-detection.js'; export interface AuthkitApplicationSetup { clientId: string; redirectUri: string; signOutUri: string; - initiateLoginUri: string; + /** Absent when there is no sign-in route to point at; `initiateLoginReason` says why. */ + initiateLoginUri?: string; + initiateLoginReason?: string; homepageUrl?: string; + /** 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; @@ -33,6 +41,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. */ @@ -44,13 +53,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 +88,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, }; } @@ -79,6 +103,16 @@ 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.', + }; + } + // Never trust incoming registration flags; they belong to an earlier attempt. + setup = { ...setup, signOutRegistered: false, ...(setup.corsOrigin !== undefined ? { corsRegistered: false } : {}) }; let callbackRegistered = false; const pending = (reason: string): AuthkitApplicationSetup => { if (!callbackRegistered) { @@ -113,10 +147,25 @@ 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)) { + 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.', @@ -216,10 +265,46 @@ export async function configureAuthkitApplication( } callbackRegistered = true; - const reasons: string[] = []; + 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)); - const initiateConflict = !!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.', @@ -251,7 +336,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 = !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) { @@ -287,22 +372,35 @@ 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 (reasons.length) return pending(reasons.join(' ')); + 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.'); + } + 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) || - saved.initiateLoginUri !== setup.initiateLoginUri || + !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.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 501f256b..1dc8a26d 100644 --- a/src/lib/completion-data.ts +++ b/src/lib/completion-data.ts @@ -9,8 +9,13 @@ 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'})`, - `Sign-out URI: ${setup.signOutUri}`, - `Initiate login URI: ${setup.initiateLoginUri} (starts sign-in; never use the callback URI)`, + ...(setup.corsOrigin !== undefined + ? [`CORS origin: ${setup.corsOrigin} (${setup.corsRegistered ? 'registered' : 'not registered or verified'})`] + : []), + `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 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/port-detection.spec.ts b/src/lib/port-detection.spec.ts index ea574cfd..421e4552 100644 --- a/src/lib/port-detection.spec.ts +++ b/src/lib/port-detection.spec.ts @@ -2,7 +2,9 @@ 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'; +import type { FrameworkConfig } from './framework-config.js'; describe('port-detection — python/Django defaults', () => { let dir: string; @@ -225,3 +227,42 @@ 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', '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 420de6ad..e511cf06 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', @@ -34,6 +36,39 @@ 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 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; +} + /** * Parse port from Vite config file. * Looks for server.port in vite.config.{ts,js,mjs} @@ -228,6 +263,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 3be91fc8..a79c07bf 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'; @@ -116,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, @@ -124,26 +136,105 @@ 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(); + }); + + it('defers URL writes for other server-side SDKs', async () => { + const { emitter, events } = record(); + await configureInstallEnvironment({ + options, + integration: 'sveltekit', + credentials: { apiKey: 'sk_test_a', clientId: 'client_a' }, + emitter, + }); + 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'])('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('says why the redirect URI and CORS origin were not set without an API key', async () => { + it('defers non-JavaScript URLs without writing .env.local', async () => { const { emitter, events } = record(); + await configureInstallEnvironment({ + options, + integration: 'ruby', + credentials: { apiKey: 'sk_test_a', clientId: 'client_a' }, + emitter, + }); + 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' }); + }); + + 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)( + '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' }, + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }, + ); + + it('does not prematurely skip client-only URLs without an API key', async () => { + const { emitter, events } = record(); + await configureInstallEnvironment({ + options, + integration: 'react', + 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(); + }); + + it('does not prematurely skip server URLs without an API key', async () => { + const { emitter, events } = record(); + await configureInstallEnvironment({ + options, + integration: 'react-router', + credentials: { clientId: 'client_a' }, + emitter, + }); + expect(events).toEqual(['config:step env-vars started', 'config:step env-vars done']); expect(fetchSpy).not.toHaveBeenCalled(); }); @@ -159,6 +250,43 @@ 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('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 () => ({ @@ -175,6 +303,27 @@ 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, + 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', + `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( @@ -189,3 +338,162 @@ 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/', + corsOrigin: 'http://localhost:8080', + initiateLoginUri: 'http://localhost:8080/auth/login', + verified: false, + }, + 'client_a', + 'sk_test_a', + ); + expect(result?.verified).toBe(true); + }); + + 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].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 }); + 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('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 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[] = []; + emitter.on('app-urls:step', ({ step, status }) => events.push(`${step} ${status}`)); + 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('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())); + 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 d7dafdb4..9eee52a6 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'; @@ -53,13 +54,12 @@ import { generateCommitMessage as generateCommitMessageAi, generatePrDescription as generatePrDescriptionAi, } from './ai-content.js'; -import { autoConfigureWorkOSEnvironment } from './workos-management.js'; import { assertSupportedNextJsRouter, getNextJsRouter, assertNextjsSignInRouteAvailable, } from '../integrations/nextjs/utils.js'; -import { detectPort, getCallbackPath } from './port-detection.js'; +import { detectPort, getClientEnvPrefix, getSignInPath, resolveRedirectUri } from './port-detection.js'; import { writeEnvLocal } from './env-writer.js'; import { getRegistry } from './registry.js'; import { observeHostFailure } from './host-probe.js'; @@ -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,29 +209,32 @@ 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) { - await autoConfigureWorkOSEnvironment(credentials.apiKey, integration, port, { - homepageUrl: installerOptions.homepageUrl, - redirectUri: installerOptions.redirectUri, - onStep: step, - }); - } else if (requiresApiKey) { - for (const id of ['redirect-uri', 'cors-origin'] as const) { - step(id, 'skipped', 'No API key was available for this install.'); - } - } + // 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'); + + // Non-JavaScript agents write their own env files in the project's format. + if (!isJavascript) return; + + 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)); @@ -239,31 +243,75 @@ 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. * * `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. + * 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, configure: () => Promise, + { 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 } : {}) }); - 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'); + if (includeCors) step('cors-origin', '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'); - else step(id, 'skipped', setup.reason); - } + 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); + else settle('initiate-login-uri'); + if (setup.signOutRegistered) step('sign-out-uri', 'done'); + else settle('sign-out-uri'); return setup; } +/** + * 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, + clientId: string, + apiKey?: string, +): Promise { + const { options: installerOptions, integration } = context; + if (!integration || integration === 'nextjs') return undefined; + const signInPath = getSignInPath(integration); + const clientOnly = (await getRegistry()).get(integration)?.config.environment.requiresApiKey === false; + const redirectUri = resolveRedirectUri(integration, installerOptions); + const setup: AuthkitApplicationSetup = { + ...buildApplicationSetup({ + clientId, + redirectUri, + homepageUrl: installerOptions.homepageUrl, + signInPath, + }), + 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, + }); +} + /** Pick the installer adapter for this process's output mode and terminal. */ export function resolveAdapterKind(options: Pick): InstallerAdapterKind { return selectInstallerAdapter({ @@ -442,9 +490,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.', @@ -466,13 +512,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/sign-in-route.ts b/src/lib/sign-in-route.ts new file mode 100644 index 00000000..794c2c1b --- /dev/null +++ b/src/lib/sign-in-route.ts @@ -0,0 +1,27 @@ +import type { FrameworkConfig } from './framework-config.js'; +import { getSignInPath } from './port-detection.js'; + +/** Pin the intended Initiate login route (Next.js has its own section). */ +export function buildSignInSection({ + metadata, + environment, +}: Pick): string { + const { integration } = metadata; + const signInPath = getSignInPath(integration); + if (!signInPath || integration === 'nextjs') return ''; + const lines = [ + '## Sign-in route (Initiate login URI)', + '', + `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. 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().", + ); + } + return `${lines.join('\n')}\n\n`; +} diff --git a/src/lib/validation/client-source.spec.ts b/src/lib/validation/client-source.spec.ts new file mode 100644 index 00000000..f19a2a95 --- /dev/null +++ b/src/lib/validation/client-source.spec.ts @@ -0,0 +1,110 @@ +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 { 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 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({ + 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 }); +}); + +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', + '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, 'const redirectUri = process.env.WORKOS_REDIRECT_URI;'); + const result = await validateInstallation('vanilla-js', project, { runBuild: false }); + 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([ + "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('checks callback configuration without resolving inline imports or custom aliases', async () => { + await file('index.html', ''); + 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.passed).toBe(true); + expect(result.issues.some((issue) => issue.message === 'The AuthKit client does not set redirectUri')).toBe(false); +}); + +describe('bounded scan', () => { + async function incomplete() { + const result = await validateInstallation('vanilla-js', project, { runBuild: false }); + expect(result.passed).toBe(true); + expect(result.issues).toContainEqual( + expect.objectContaining({ + 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('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 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 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 8c9cea02..a27922f5 100644 --- a/src/lib/validation/validator.spec.ts +++ b/src/lib/validation/validator.spec.ts @@ -749,6 +749,215 @@ describe('validateInstallation', () => { }); }); + 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('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('warning'); + expect(signInIssue(result.issues)?.message).toContain('requires browser verification'); + }); + + 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( + join(testDir, 'src', 'Login.tsx'), + "function Login() { useEffect(() => { if (window.location.pathname === '/login') signIn(); }, []); }", + ); + + const result = await validateInstallation('react', testDir, { runBuild: false }); + + expect(signInIssue(result.issues)?.severity).toBe('warning'); + }); + + 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)?.severity).toBe('warning'); + }); + + 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('/login')"); + + const result = await validateInstallation('vanilla-js', testDir, { runBuild: false }); + + expect(signInIssue(result.issues)).toBeDefined(); + }); + + it.each([ + "const path = window.location.pathname;\nif (path === '/login') signIn();", + "if ('/login' === location.pathname) signIn();", + ])('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)?.severity).toBe('warning'); + }); + + 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('warning'); + }); + + 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', 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 }); + + expect(signInIssue(result.issues)?.severity).toBe('warning'); + expect(redirectIssue(result.issues)).toBeUndefined(); + }); + + 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, 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'), + '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;'); + mkdirSync(join(testDir, 'src'), { recursive: true }); + writeFileSync( + join(testDir, 'src', 'index.tsx'), + '', + ); + + const result = await validateInstallation('react', testDir, { runBuild: false }); + + expect(envReadIssue(result.issues)).toBeUndefined(); + }); + + 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

'); + + const result = await validateInstallation('vanilla-js', testDir, { runBuild: false }); + + expect(signInIssue(result.issues)).toBeDefined(); + }); + + 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(envReadIssue(result.issues)).toBeUndefined(); + }); + + it.each([ + 'import.meta.env.WORKOS_REDIRECT_URI', + 'import.meta.env.REACT_APP_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' } })); + 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 process.env.WORKOS_REDIRECT_URI, but the installer exposes process.env.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( + 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 });', + ); + writeFileSync(join(testDir, '.env.local'), 'VITE_WORKOS_REDIRECT_URI=http://localhost:5173/callback\n'); + + const result = await validateInstallation('vanilla-js', testDir, { runBuild: false }); + + expect(redirectIssue(result.issues)).toBeUndefined(); + }); + }); + 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..1a80d2d4 100644 --- a/src/lib/validation/validator.ts +++ b/src/lib/validation/validator.ts @@ -1,10 +1,10 @@ -import { readFile } from 'fs/promises'; +import { open, readFile } from 'fs/promises'; 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'; -import { detectPort } 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' }; @@ -258,6 +258,10 @@ export async function validateFrameworkSpecific(framework: string, projectDir: s } case 'react': await validateReactProviderWrapping(projectDir, issues); + await validateClientOnlyApp(framework, projectDir, issues); + break; + case 'vanilla-js': + await validateClientOnlyApp(framework, projectDir, issues); break; case 'react-router': await validateReactRouterRedirectUri(projectDir, issues); @@ -272,6 +276,123 @@ export async function validateFrameworkSpecific(framework: string, projectDir: s return issues; } +/** 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); + if (signInPath) + issues.push({ + type: 'file', + 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.`, + }); + const { sources, complete } = await readClientSource(projectDir, prefix); + if (!complete) + issues.push({ + type: 'file', + 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 (complete && !sources.some((content) => content.includes('redirectUri'))) + issues.push({ + type: 'pattern', + severity: 'error', + 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.', + }); + if (!prefix) return; + const expected = BROWSER_REDIRECT_ENV[prefix]; + const reads = new Set( + sources.flatMap((content) => [...content.matchAll(REDIRECT_ENV_REFERENCE)].map(([read]) => read)), + ); + reads.delete(expected); + for (const read of reads) + issues.push({ + type: 'env', + severity: 'error', + 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>; + +/** 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 MAX_SOURCE_FILES = 256; +const MAX_SOURCE_BYTES = 256 * 1024; +const MAX_SCAN_BYTES = 4 * 1024 * 1024; + +/** 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; + } +} + +/** + * Bounded checks of conventional client source roots, not a module 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. + * Incomplete scans are inconclusive, not evidence of missing configuration. + */ +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 }; +} + /** * 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. diff --git a/src/lib/workos-management.spec.ts b/src/lib/workos-management.spec.ts index 1bf619e2..38a9143e 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'; @@ -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 })); @@ -290,11 +296,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 +311,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 +330,93 @@ 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'], + ['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..924d07c3 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 { @@ -83,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) { @@ -157,6 +161,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 { @@ -184,9 +199,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 +226,33 @@ 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; + } + + // 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...'); - 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'); @@ -228,7 +272,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 }; @@ -239,7 +283,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 @@ -260,12 +304,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;