diff --git a/src/lib/adapters/headless-adapter.spec.ts b/src/lib/adapters/headless-adapter.spec.ts index 7ff90d12..646a9cf9 100644 --- a/src/lib/adapters/headless-adapter.spec.ts +++ b/src/lib/adapters/headless-adapter.spec.ts @@ -413,6 +413,14 @@ describe('HeadlessAdapter', () => { it('spreads structured completion fields into the complete event when present', async () => { const adapter = createAdapter(); await adapter.start(); + const applicationSetup = { + clientId: 'client_app', + redirectUri: 'http://localhost:3000/callback', + signOutUri: 'http://localhost:3000/', + initiateLoginUri: 'http://localhost:3000/sign-in', + verified: false, + reason: 'No dashboard session.', + }; emitter.emit('complete', { success: true, @@ -423,6 +431,7 @@ describe('HeadlessAdapter', () => { url: 'http://localhost:3000', files: ['a.ts'], nextSteps: ['x'], + applicationSetup, docsUrl: 'https://d', dashboardUrl: 'https://dash', }, @@ -437,6 +446,7 @@ describe('HeadlessAdapter', () => { url: 'http://localhost:3000', files: ['a.ts'], nextSteps: ['x'], + applicationSetup, }), ); await adapter.stop(); diff --git a/src/lib/adapters/headless-adapter.ts b/src/lib/adapters/headless-adapter.ts index 819ab504..6766beb4 100644 --- a/src/lib/adapters/headless-adapter.ts +++ b/src/lib/adapters/headless-adapter.ts @@ -420,6 +420,7 @@ export class HeadlessAdapter implements InstallerAdapter { url: completion.url, files: completion.files, nextSteps: completion.nextSteps, + ...(completion.applicationSetup ? { applicationSetup: completion.applicationSetup } : {}), } : {}), }); diff --git a/src/lib/agent-runner.spec.ts b/src/lib/agent-runner.spec.ts new file mode 100644 index 00000000..aaae7ba9 --- /dev/null +++ b/src/lib/agent-runner.spec.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FrameworkConfig } from './framework-config.js'; +import type { InstallerOptions } from '../utils/types.js'; + +vi.mock('./skills-assets.js', () => ({ getReference: vi.fn() })); +vi.mock('./agent-interface.js', () => ({ initializeAgent: vi.fn(), runAgent: vi.fn() })); +vi.mock('./validation/index.js', () => ({ validateInstallation: vi.fn(), quickCheckValidateAndFormat: vi.fn() })); +vi.mock('./validation/security-checks.js', () => ({ + runInstallSecurityChecks: vi.fn(async () => ({ findings: [], blocking: [] })), + securityFindingsToIssues: vi.fn(() => []), + formatSecurityFindingsForAgent: vi.fn(() => ''), +})); +vi.mock('../steps/index.js', () => ({})); +vi.mock('./workos-management.js', () => ({})); +vi.mock('./env-writer.js', () => ({})); +vi.mock('../utils/ui-utils.js', () => ({ + ensurePackageIsInstalled: vi.fn(), + getOrAskForWorkOSCredentials: vi.fn(async () => ({ apiKey: 'test-key', clientId: 'client_test' })), + getPackageDotJson: vi.fn(async () => ({ dependencies: { next: '16.3.5' } })), + isUsingTypeScript: vi.fn(() => true), +})); +vi.mock('../utils/analytics.js', () => ({ + analytics: { setTag: vi.fn(), capture: vi.fn(), shutdown: vi.fn() }, +})); + +import { getReference } from './skills-assets.js'; +import { initializeAgent, runAgent } from './agent-interface.js'; +import { runAgentInstaller } from './agent-runner.js'; +import { validateInstallation, quickCheckValidateAndFormat } from './validation/index.js'; + +const options: InstallerOptions = { + debug: false, + forceInstall: false, + installDir: '/tmp/test-authkit-app', + local: false, + ci: true, + skipAuth: true, + clientId: 'client_test', + noValidate: true, +}; + +const config: FrameworkConfig = { + metadata: { + name: 'Next.js', + integration: 'nextjs', + skillName: 'workos-authkit-nextjs', + language: 'javascript', + docsUrl: 'https://workos.com/docs/authkit/nextjs', + stability: 'stable', + priority: 100, + }, + detection: { packageName: 'next', packageDisplayName: 'Next.js', getVersion: () => '16.3.5' }, + environment: { requiresApiKey: true, uploadToHosting: false, getEnvVars: () => ({}) }, + analytics: { getTags: () => ({}) }, + prompts: { getAdditionalContextLines: () => ['Router: app'] }, + ui: { successMessage: 'Installed', getOutroChanges: () => [], getOutroNextSteps: () => [] }, +}; + +const setupContent = 'Configure and read back Sign-out URI and Initiate login URI. Report unverified flows.'; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getReference).mockImplementation(async (name) => { + if (name === 'workos-authkit-setup') return setupContent; + return `Instructions from ${name}`; + }); + vi.mocked(runAgent).mockResolvedValue({}); + vi.mocked(quickCheckValidateAndFormat).mockResolvedValue(null); + vi.mocked(validateInstallation).mockResolvedValue({ passed: true, framework: 'nextjs', issues: [], durationMs: 0 }); +}); + +describe('installer prompt', () => { + it.each(['javascript', 'php'] as const)('injects shared application setup for %s integrations', async (language) => { + const framework = { + ...config, + metadata: { + ...config.metadata, + language, + integration: language === 'javascript' ? 'nextjs' : 'php', + skillName: language === 'javascript' ? 'workos-authkit-nextjs' : 'workos-php', + }, + }; + await runAgentInstaller(framework, options); + + const prompt = vi.mocked(runAgent).mock.calls[0][1]; + expect(getReference).toHaveBeenCalledWith('workos-authkit-setup'); + expect(prompt).toContain(setupContent); + expect(prompt).toContain(`Instructions from ${framework.metadata.skillName}`); + expect(prompt).toContain('Router: app'); + expect(prompt).not.toContain('test-key'); + if (language === 'javascript') { + expect(prompt).toContain('NEXT_PUBLIC_WORKOS_REDIRECT_URI'); + expect(prompt).toContain('Instructions from workos-authkit-base'); + } else { + expect(prompt).toContain('WORKOS_REDIRECT_URI'); + expect(prompt).not.toContain('NEXT_PUBLIC_WORKOS_REDIRECT_URI'); + expect(getReference).not.toHaveBeenCalledWith('workos-authkit-base'); + } + }); + + it('blocks success when an application route is still missing after retries', async () => { + vi.mocked(validateInstallation).mockResolvedValue({ + passed: false, + framework: 'nextjs', + durationMs: 0, + issues: [{ type: 'file', severity: 'error', message: 'Missing sign-in route', hint: 'Create /sign-in' }], + }); + await expect(runAgentInstaller(config, { ...options, noValidate: false })).rejects.toThrow('Missing sign-in route'); + const retry = vi.mocked(runAgent).mock.calls[0][5]; + expect(await retry!.validateAndFormat(options.installDir)).toContain('Create /sign-in'); + }); + + it('does not start the agent when the bundled setup reference is missing', async () => { + vi.mocked(getReference).mockImplementation(async (name) => { + if (name === 'workos-authkit-setup') throw new Error('Missing bundled setup reference'); + return `Instructions from ${name}`; + }); + + await expect(runAgentInstaller(config, options)).rejects.toThrow('Missing bundled setup reference'); + expect(initializeAgent).not.toHaveBeenCalled(); + expect(runAgent).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/agent-runner.ts b/src/lib/agent-runner.ts index bcbf95c4..0f087409 100644 --- a/src/lib/agent-runner.ts +++ b/src/lib/agent-runner.ts @@ -18,7 +18,7 @@ import { analytics } from '../utils/analytics.js'; import { INSTALLER_INTERACTION_EVENT_NAME } from './constants.js'; import { initializeAgent, runAgent, type RetryConfig } from './agent-interface.js'; import { uploadEnvironmentVariablesStep } from '../steps/index.js'; -import { autoConfigureWorkOSEnvironment } from './workos-management.js'; +import { autoConfigureWorkOSEnvironment, configureCallbackUri } from './workos-management.js'; import { detectPort, getCallbackPath } from './port-detection.js'; import { writeEnvLocal } from './env-writer.js'; @@ -64,12 +64,18 @@ export async function runAgentInstaller(config: FrameworkConfig, options: Instal // Auto-configure WorkOS environment (redirect URI, CORS, homepage) // Skip if caller already handled this (prevents duplicate dashboard config output) + // Next.js URL setup runs natively after code validation, with client-ID + // targeting and read-back. Do not pre-write unrelated homepage/CORS settings. if (!callerHandledConfig && apiKey && config.environment.requiresApiKey) { const port = detectPort(config.metadata.integration, options.installDir); - await autoConfigureWorkOSEnvironment(apiKey, config.metadata.integration, port, { - homepageUrl: options.homepageUrl, - redirectUri: options.redirectUri, - }); + if (config.metadata.integration === 'nextjs') { + await configureCallbackUri(apiKey, options.redirectUri || `http://localhost:${port}${getCallbackPath('nextjs')}`); + } else { + await autoConfigureWorkOSEnvironment(apiKey, config.metadata.integration, port, { + homepageUrl: options.homepageUrl, + redirectUri: options.redirectUri, + }); + } } // Gather framework-specific context (e.g., Next.js router, React Native platform) @@ -136,8 +142,11 @@ export async function runAgentInstaller(config: FrameworkConfig, options: Instal validateAndFormat: async (workingDirectory: string) => { const quickPrompt = await quickCheckValidateAndFormat(workingDirectory); const security = await runInstallSecurityChecks(integration, workingDirectory); - if (quickPrompt === null && security.blocking.length === 0) return null; - return [quickPrompt, formatSecurityFindingsForAgent(security.findings)] + const installation = await validateInstallation(integration, workingDirectory, { runBuild: false }); + const errors = installation.issues.filter((issue) => issue.severity === 'error'); + if (quickPrompt === null && security.blocking.length === 0 && installation.passed) return null; + const completenessPrompt = errors.map((issue) => `${issue.message}. ${issue.hint ?? ''}`).join('\n'); + return [quickPrompt, completenessPrompt, formatSecurityFindingsForAgent(security.findings)] .filter((p): p is string => Boolean(p)) .join('\n\n'); }, @@ -205,6 +214,15 @@ export async function runAgentInstaller(config: FrameworkConfig, options: Instal await analytics.shutdown('error'); throw new Error(formatBlockingSecurityError(security.blocking)); } + if (!validationResult.passed) { + await analytics.shutdown('error'); + throw new Error( + `Installation validation failed:\n${validationResult.issues + .filter((issue) => issue.severity === 'error') + .map((issue) => `${issue.message}. ${issue.hint ?? ''}`) + .join('\n')}`, + ); + } } // Track retry metrics AFTER the security gate. `passed_after_retry` must @@ -281,9 +299,12 @@ async function buildIntegrationPrompt( // Base template has JS-centric assumptions (node_modules, lockfiles, AuthKitProvider) // so only load it for JavaScript integrations; backend SDKs bypass this entirely const isJavaScript = config.metadata.language === 'javascript'; - const [baseContent, refContent] = await Promise.all([ + // Inline shared setup too: relative links in the framework reference do not + // resolve from the app directory, and agents can skip them entirely. + const [baseContent, refContent, setupContent] = await Promise.all([ isJavaScript ? getReference('workos-authkit-base') : Promise.resolve(''), getReference(skillName), + getReference('workos-authkit-setup'), ]); // Build env var list dynamically based on what was actually configured @@ -311,6 +332,23 @@ ${baseContent ? `## General Guidelines\n\n${baseContent}\n\n` : ''}## Integratio ${refContent} +## Required Application Setup and Verification + +${setupContent} + +## Installer execution boundary + +The setup reference above is already included in this prompt. Do not read a relative workos-authkit-setup.md from the app directory. +The agent's shell permissions do not allow WorkOS management commands. Do not run workos, install another CLI, use curl or SDK scripts to bypass that boundary, or attempt dashboard authentication. Implement and validate the app code only. The installer handles supported dashboard configuration outside the agent after code validation; unavailable configuration must remain explicitly unverified. +${ + config.metadata.integration === 'nextjs' + ? ` +Create a dedicated /sign-in GET route in the App Router using getSignInUrl() from @workos-inc/authkit-nextjs and redirect(await getSignInUrl()) from next/navigation. Keep the OAuth callback using handleAuth() separate. The Initiate login URI is the app origin plus /sign-in, NEVER the callback URI. Read existing files before editing; do not replace an unrelated existing sign-in flow. Keep /sign-in public and follow the SDK README for PKCE cookie handling. +` + : '' +} +Do not claim the full integration or browser flows are verified. Report code implementation separately from application configuration and browser testing. + 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 new file mode 100644 index 00000000..7101e674 --- /dev/null +++ b/src/lib/authkit-application-setup.spec.ts @@ -0,0 +1,291 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +vi.mock('./command-auth.js', () => ({ refreshIfExpired: vi.fn() })); +vi.mock('./environment-target.js', () => ({ fetchTeamEnvironments: vi.fn(), resolveEnvironmentTarget: vi.fn() })); +vi.mock('./dashboard-graphql.js', () => ({ dashboardGraphqlRequest: vi.fn() })); +vi.mock('../catalog/operation.js', () => ({ + getOperation: (name: string) => ({ name }), + resolveExecutableDocument: (operation: { name: string }) => operation.name, +})); + +import { refreshIfExpired } from './command-auth.js'; +import { fetchTeamEnvironments, resolveEnvironmentTarget } from './environment-target.js'; +import { dashboardGraphqlRequest } from './dashboard-graphql.js'; +import { configureAuthkitApplication, readNextjsApplicationSetup } from './authkit-application-setup.js'; +import { applicationSetupNextSteps } from './completion-data.js'; + +const setup = { + clientId: 'client_app', + redirectUri: 'http://localhost:4000/callback', + signOutUri: 'http://localhost:4000/', + initiateLoginUri: 'http://localhost:4000/sign-in', + verified: false, +}; +let application: { + id: string; + clientId: string; + redirectUris: { uri: string; isDefault?: boolean }[]; + logoutUris: { id?: string; uri: string; isDefault: boolean }[]; + initiateLoginUri: string | null; + appHomepageUrl?: string; +}; +const writes = () => + vi + .mocked(dashboardGraphqlRequest) + .mock.calls.filter( + ([name, options]) => + name !== 'defaultAuthkitApplication' && !(options.variables?.input as { dryRun?: boolean })?.dryRun, + ); + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(refreshIfExpired).mockResolvedValue({ accessToken: 'test-token', refreshed: false }); + vi.mocked(fetchTeamEnvironments).mockResolvedValue([ + { id: 'env_app', name: 'Sandbox', sandbox: true, clientId: setup.clientId }, + ]); + vi.mocked(resolveEnvironmentTarget).mockResolvedValue({ environmentId: 'env_app', source: 'flag' }); + application = { + id: 'app_1', + clientId: setup.clientId, + redirectUris: [{ uri: setup.redirectUri, isDefault: true }], + logoutUris: [{ id: 'uri_old', uri: 'https://old.example/', isDefault: false }], + initiateLoginUri: null, + }; + vi.mocked(dashboardGraphqlRequest).mockImplementation(async (name, options) => { + if (name === 'defaultAuthkitApplication') return { defaultUserlandApplication: structuredClone(application) }; + if (name === 'setAuthkitApplicationLogoutUris') { + const input = options.variables!.input as { + applicationId: string; + logoutUris: typeof application.logoutUris; + dryRun: boolean; + }; + expect(input.applicationId).toBe('app_1'); + if (!input.dryRun) application.logoutUris = input.logoutUris; + return { setUserlandApplicationLogoutUris: { __typename: 'LogoutUrisSet' } }; + } + if (name === 'setRedirectUris') { + const input = options.variables!.input as { + applicationId: string; + redirectUris: typeof application.redirectUris; + dryRun: boolean; + }; + expect(input.applicationId).toBe('app_1'); + if (!input.dryRun) application.redirectUris = input.redirectUris; + return { setRedirectUris: { __typename: 'RedirectUrisSet' } }; + } + if (name === 'updateAuthkitApplication') { + const input = options.variables!.input as { + applicationId: string; + initiateLoginUri?: string; + appHomepageUrl?: string; + }; + expect(input.applicationId).toBe('app_1'); + if (input.initiateLoginUri !== undefined) application.initiateLoginUri = input.initiateLoginUri; + if (input.appHomepageUrl !== undefined) application.appHomepageUrl = input.appHomepageUrl; + return { updateUserlandApplication: { __typename: 'UserlandApplicationUpdated' } }; + } + throw new Error(`Unexpected operation: ${name}`); + }); +}); + +describe('native application URL setup', () => { + it('validates, preserves existing URLs, writes to the matched application, and reads back', async () => { + const result = await configureAuthkitApplication(setup, setup.clientId); + expect(result.verified).toBe(true); + expect(application.logoutUris).toContainEqual({ id: 'uri_old', uri: 'https://old.example/', isDefault: false }); + expect(application.logoutUris).toContainEqual({ uri: setup.signOutUri, isDefault: true }); + expect(application.initiateLoginUri).toBe(setup.initiateLoginUri); + expect(resolveEnvironmentTarget).toHaveBeenCalledWith('test-token', { flagValue: 'env_app', forMutation: true }); + for (const [, options] of vi.mocked(dashboardGraphqlRequest).mock.calls) + expect(options.environmentId).toBe('env_app'); + expect(vi.mocked(dashboardGraphqlRequest).mock.calls.at(-1)?.[0]).toBe('defaultAuthkitApplication'); + expect(writes()).toHaveLength(2); + }); + + it('leaves the homepage alone unless --homepage-url was explicitly supplied', async () => { + application.appHomepageUrl = 'https://existing.example/'; + expect((await configureAuthkitApplication(setup, setup.clientId)).verified).toBe(true); + expect(application.appHomepageUrl).toBe('https://existing.example/'); + const result = await configureAuthkitApplication( + { ...setup, homepageUrl: 'https://requested.example/' }, + setup.clientId, + ); + expect(result.verified).toBe(true); + expect(application.appHomepageUrl).toBe('https://requested.example/'); + expect(application.initiateLoginUri).toBe(setup.initiateLoginUri); + }); + + it('preserves an equivalent existing root URL without adding a duplicate', async () => { + application.logoutUris = [{ uri: 'http://localhost:4000', isDefault: true }]; + application.initiateLoginUri = setup.initiateLoginUri; + const result = await configureAuthkitApplication(setup, setup.clientId); + expect(result.verified).toBe(true); + expect(result.signOutUri).toBe('http://localhost:4000'); + expect(writes()).toHaveLength(0); + }); + + it('does not write when settings already match', async () => { + application.logoutUris.push({ uri: setup.signOutUri, isDefault: true }); + application.initiateLoginUri = setup.initiateLoginUri; + expect((await configureAuthkitApplication(setup, setup.clientId)).verified).toBe(true); + expect(writes()).toHaveLength(0); + }); + + it.each(['sign-out', 'initiate-login'] as const)('preserves an existing conflicting %s setting', async (setting) => { + if (setting === 'sign-out') application.logoutUris[0].isDefault = true; + else application.initiateLoginUri = 'https://old.example/login'; + const result = await configureAuthkitApplication(setup, setup.clientId); + expect(result.verified).toBe(false); + expect(result.reason).toContain('left unchanged'); + expect(writes()).toHaveLength(0); + }); + + it('does not use the active profile when the client ID cannot be matched', async () => { + vi.mocked(fetchTeamEnvironments).mockResolvedValue([ + { id: 'env_other', name: 'Other', clientId: 'client_other', sandbox: true }, + ]); + expect((await configureAuthkitApplication(setup, setup.clientId)).verified).toBe(false); + expect(dashboardGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('does not mutate production or a different application', async () => { + vi.mocked(fetchTeamEnvironments).mockResolvedValue([ + { id: 'env_prod', name: 'Production', clientId: setup.clientId, sandbox: false }, + ]); + expect((await configureAuthkitApplication(setup, setup.clientId)).verified).toBe(false); + expect(dashboardGraphqlRequest).not.toHaveBeenCalled(); + vi.mocked(fetchTeamEnvironments).mockResolvedValue([ + { id: 'env_app', name: 'Sandbox', clientId: setup.clientId, sandbox: true }, + ]); + application.clientId = 'client_other'; + expect((await configureAuthkitApplication(setup, setup.clientId)).verified).toBe(false); + expect(writes()).toHaveLength(0); + }); + + it('reports missing dashboard access without launching authentication or attempting a write', async () => { + vi.mocked(refreshIfExpired).mockResolvedValue(null); + const result = await configureAuthkitApplication(setup, setup.clientId); + expect(result.verified).toBe(false); + expect(result.reason).toContain('No dashboard session'); + expect(fetchTeamEnvironments).not.toHaveBeenCalled(); + expect(applicationSetupNextSteps(result).join('\n')).toContain(setup.initiateLoginUri); + }); + + it('refuses a changed app client ID before accessing the account', async () => { + expect((await configureAuthkitApplication(setup, 'client_other')).verified).toBe(false); + expect(refreshIfExpired).not.toHaveBeenCalled(); + }); + + it('stops when environment validation fails', async () => { + vi.mocked(resolveEnvironmentTarget).mockRejectedValue(new Error('environment_stale')); + expect((await configureAuthkitApplication(setup, setup.clientId)).verified).toBe(false); + expect(dashboardGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('registers a missing callback while retaining an existing callback and its default', async () => { + application.redirectUris = [{ uri: 'https://old.example/callback', isDefault: true }]; + expect((await configureAuthkitApplication(setup, setup.clientId)).verified).toBe(true); + expect(application.redirectUris).toEqual([ + { uri: 'https://old.example/callback', isDefault: true }, + { uri: setup.redirectUri, isDefault: false }, + ]); + expect(writes()).toHaveLength(3); + }); + + it('rejects incomplete application reads rather than overwriting an unknown list', async () => { + vi.mocked(dashboardGraphqlRequest).mockResolvedValue({ + defaultUserlandApplication: { id: 'app_1', clientId: setup.clientId }, + }); + expect((await configureAuthkitApplication(setup, setup.clientId)).verified).toBe(false); + expect(writes()).toHaveLength(0); + }); + + it('does not apply any mutation when the sign-out dry run is rejected', async () => { + const original = vi.mocked(dashboardGraphqlRequest).getMockImplementation()!; + vi.mocked(dashboardGraphqlRequest).mockImplementation(async (name, options) => { + if (name === 'setAuthkitApplicationLogoutUris') + return { setUserlandApplicationLogoutUris: { __typename: 'InvalidLogoutUriError' } }; + return original(name, options); + }); + const result = await configureAuthkitApplication(setup, setup.clientId); + expect(result.verified).toBe(false); + expect(result.reason).toContain('validation failed'); + expect(writes()).toHaveLength(0); + }); + + it('rejects ambiguous environment matches before any request can mutate settings', async () => { + vi.mocked(fetchTeamEnvironments).mockResolvedValue([ + { id: 'env_one', name: 'Sandbox', clientId: setup.clientId, sandbox: true }, + { id: 'env_two', name: 'Sandbox', clientId: setup.clientId, sandbox: true }, + ]); + expect((await configureAuthkitApplication(setup, setup.clientId)).verified).toBe(false); + expect(dashboardGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('detects a concurrent edit between validation and the full-list write', async () => { + const original = vi.mocked(dashboardGraphqlRequest).getMockImplementation()!; + vi.mocked(dashboardGraphqlRequest).mockImplementation(async (name, options) => { + const result = await original(name, options); + if (name === 'setAuthkitApplicationLogoutUris') + application.logoutUris.push({ uri: 'https://concurrent.example/', isDefault: false }); + return result; + }); + expect((await configureAuthkitApplication(setup, setup.clientId)).reason).toContain('changed during setup'); + expect(writes()).toHaveLength(0); + }); + + it('does not treat a successful mutation response as verified configuration', async () => { + const original = vi.mocked(dashboardGraphqlRequest).getMockImplementation()!; + vi.mocked(dashboardGraphqlRequest).mockImplementation(async (name, options) => { + const result = await original(name, options); + if (name === 'updateAuthkitApplication') application.initiateLoginUri = null; + return result; + }); + expect((await configureAuthkitApplication(setup, setup.clientId)).reason).toContain('read-back'); + }); + + it('reports a partial write as unverified without leaking the underlying error', async () => { + const original = vi.mocked(dashboardGraphqlRequest).getMockImplementation()!; + vi.mocked(dashboardGraphqlRequest).mockImplementation(async (name, options) => { + if (name === 'updateAuthkitApplication') throw new Error('private backend details'); + return original(name, options); + }); + const result = await configureAuthkitApplication(setup, setup.clientId); + expect(result.verified).toBe(false); + expect(result.reason).not.toContain('private backend details'); + expect(application.logoutUris.some((uri) => uri.uri === setup.signOutUri)).toBe(true); + }); +}); + +describe('app URL derivation', () => { + let directory: string; + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'authkit-urls-')); + }); + afterEach(async () => { + await rm(directory, { recursive: true, force: true }); + }); + + it('uses the actual callback origin and custom port while keeping the two routes distinct', async () => { + await writeFile( + join(directory, '.env.local'), + 'WORKOS_CLIENT_ID=client_app\nNEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:4567/auth/callback\n', + ); + const result = await readNextjsApplicationSetup(directory); + expect(result.initiateLoginUri).toBe('http://localhost:4567/sign-in'); + expect(result.signOutUri).toBe('http://localhost:4567/'); + expect(result.redirectUri).toBe('http://localhost:4567/auth/callback'); + expect(result.verified).toBe(false); + }); + + it('rejects using the same path for sign-in and callback', async () => { + await writeFile( + join(directory, '.env.local'), + 'WORKOS_CLIENT_ID=client_app\nNEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:3000/sign-in\n', + ); + await expect(readNextjsApplicationSetup(directory)).rejects.toThrow('cannot use /sign-in'); + }); +}); diff --git a/src/lib/authkit-application-setup.ts b/src/lib/authkit-application-setup.ts new file mode 100644 index 00000000..c0014790 --- /dev/null +++ b/src/lib/authkit-application-setup.ts @@ -0,0 +1,259 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { parseEnvFile } from '../utils/env-parser.js'; +import { refreshIfExpired } from './command-auth.js'; +import { fetchTeamEnvironments, resolveEnvironmentTarget } from './environment-target.js'; +import { dashboardGraphqlRequest } from './dashboard-graphql.js'; +import { getOperation, resolveExecutableDocument } from '../catalog/operation.js'; + +export interface AuthkitApplicationSetup { + clientId: string; + redirectUri: string; + signOutUri: string; + initiateLoginUri: string; + homepageUrl?: string; + verified: boolean; + reason?: string; +} + +interface Uri { + id?: string | null; + uri: string; + isDefault?: boolean | null; +} + +interface Application { + id: string; + clientId: string; + redirectUris: Uri[]; + logoutUris: Uri[]; + initiateLoginUri: string | null; + appHomepageUrl?: string | null; +} + +/** Use the app's saved callback, not the active profile or a guessed localhost port. */ +export async function readNextjsApplicationSetup( + installDir: string, + homepageUrl?: string, +): Promise { + const env = parseEnvFile(await readFile(join(installDir, '.env.local'), 'utf8')); + 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.'); + 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') { + throw new Error( + 'The OAuth callback cannot use /sign-in; that route starts authentication. Use a separate callback.', + ); + } + if (homepageUrl !== undefined) { + const homepage = new URL(homepageUrl); + if (!['http:', 'https:'].includes(homepage.protocol) || homepage.username || homepage.password) { + throw new Error('The homepage must be an HTTP(S) URL without credentials.'); + } + } + return { + clientId, + redirectUri, + ...(homepageUrl !== undefined ? { homepageUrl } : {}), + signOutUri: `${callback.origin}/`, + initiateLoginUri: `${callback.origin}/sign-in`, + verified: false, + }; +} + +/** + * Native installer configuration, never agent-controlled shell access. + * Only the default application whose client ID matches this install in a + * confirmed sandbox can be changed. Existing defaults/URLs are never replaced + * with different values. Other cases return concrete manual setup instructions. + */ +export async function configureAuthkitApplication( + setup: AuthkitApplicationSetup, + expectedClientId: string, +): Promise { + const pending = (reason: string): AuthkitApplicationSetup => ({ ...setup, verified: false, reason }); + const isSignOutDestination = (uri: string): boolean => { + try { + return new URL(uri).href === new URL(setup.signOutUri).href; + } catch { + return false; + } + }; + if (setup.clientId !== expectedClientId) { + return pending('The app client ID changed during installation. Confirm the application before configuring it.'); + } + try { + const session = await refreshIfExpired(); + if (!session) + return pending( + 'No dashboard session is available. Sign in or claim the environment, then configure the URLs in the dashboard.', + ); + const environments = await fetchTeamEnvironments(session.accessToken); + const matches = environments.filter((environment) => environment.clientId === setup.clientId); + if (matches.length !== 1) return pending('Could not uniquely match the app client ID to a WorkOS environment.'); + const environment = matches[0]; + if (environment.sandbox !== true) + return pending( + 'Automatic URL setup is restricted to sandbox environments. Configure this environment explicitly in the dashboard.', + ); + const target = await resolveEnvironmentTarget(session.accessToken, { + flagValue: environment.id, + forMutation: true, + }); + const request = (name: string, variables: Record): Promise => + dashboardGraphqlRequest(resolveExecutableDocument(getOperation(name)), { + token: session.accessToken, + environmentId: target.environmentId, + variables, + }); + const readApplication = async (): Promise => { + const data = await request<{ defaultUserlandApplication: Application | null }>('defaultAuthkitApplication', { + environmentId: target.environmentId, + }); + const application = data.defaultUserlandApplication; + if ( + !application || + application.clientId !== setup.clientId || + !application.id || + !Array.isArray(application.logoutUris) || + !Array.isArray(application.redirectUris) || + !(application.initiateLoginUri === null || typeof application.initiateLoginUri === 'string') || + !application.logoutUris.every( + (uri) => typeof uri.uri === 'string' && (uri.isDefault === null || typeof uri.isDefault === 'boolean'), + ) || + !application.redirectUris.every( + (uri) => typeof uri.uri === 'string' && (uri.isDefault === null || typeof uri.isDefault === 'boolean'), + ) + ) { + throw new Error('Application configuration unavailable'); + } + return application; + }; + const original = await readApplication(); + const defaults = original.logoutUris.filter((uri) => uri.isDefault); + if (defaults.length > 1 || defaults.some((uri) => !isSignOutDestination(uri.uri))) { + return pending( + 'An existing sign-out default differs from this app. It was left unchanged; confirm the intended default in the dashboard.', + ); + } + if (original.initiateLoginUri && original.initiateLoginUri !== setup.initiateLoginUri) { + return pending( + 'An existing Initiate login URI differs from this app. It was left unchanged; confirm the intended sign-in route in the dashboard.', + ); + } + + const needsLogout = !original.logoutUris.some((uri) => isSignOutDestination(uri.uri) && uri.isDefault); + if (needsLogout) { + const logoutUris = original.logoutUris.map((uri) => ({ ...uri, isDefault: isSignOutDestination(uri.uri) })); + if (!logoutUris.some((uri) => isSignOutDestination(uri.uri))) + logoutUris.push({ uri: setup.signOutUri, isDefault: true }); + const input = { applicationId: original.id, logoutUris }; + const validate = await request<{ setUserlandApplicationLogoutUris: { __typename: string } }>( + 'setAuthkitApplicationLogoutUris', + { input: { ...input, dryRun: true } }, + ); + if (validate.setUserlandApplicationLogoutUris.__typename !== 'LogoutUrisSet') { + return pending('Sign-out URL validation failed. Existing settings were not changed.'); + } + // Full-list setters have no compare-and-swap API. Detect changes during + // validation rather than knowingly overwriting another editor's work. + const current = await readApplication(); + if (JSON.stringify(current) !== JSON.stringify(original)) { + return pending('Application settings changed during setup. Recheck them before applying changes.'); + } + const saved = await request<{ setUserlandApplicationLogoutUris: { __typename: string } }>( + 'setAuthkitApplicationLogoutUris', + { input: { ...input, dryRun: false } }, + ); + if (saved.setUserlandApplicationLogoutUris.__typename !== 'LogoutUrisSet') { + return pending('Could not save the sign-out URL. Check the dashboard before continuing.'); + } + } + if ( + original.initiateLoginUri !== setup.initiateLoginUri || + (setup.homepageUrl !== undefined && original.appHomepageUrl !== setup.homepageUrl) + ) { + const current = await readApplication(); + if ( + current.id !== original.id || + (current.initiateLoginUri && current.initiateLoginUri !== setup.initiateLoginUri) + ) { + return pending('The application or Initiate login URI changed during setup. It was not overwritten.'); + } + if ( + !current.initiateLoginUri || + (setup.homepageUrl !== undefined && current.appHomepageUrl !== setup.homepageUrl) + ) { + const saved = await request<{ updateUserlandApplication: { __typename: string } }>('updateAuthkitApplication', { + input: { + applicationId: original.id, + ...(!current.initiateLoginUri ? { initiateLoginUri: setup.initiateLoginUri } : {}), + ...(setup.homepageUrl !== undefined ? { appHomepageUrl: setup.homepageUrl } : {}), + }, + }); + if (saved.updateUserlandApplication.__typename !== 'UserlandApplicationUpdated') { + return pending( + 'Could not save application URLs. Check the Initiate login URI and any requested homepage in the dashboard.', + ); + } + } + } + if (!original.redirectUris.some((uri) => uri.uri === setup.redirectUri)) { + const current = await readApplication(); + if (current.id !== original.id) return pending('The application changed during setup. Recheck its URLs.'); + if (!current.redirectUris.some((uri) => uri.uri === setup.redirectUri)) { + const input = { + applicationId: current.id, + redirectUris: [ + ...current.redirectUris, + { uri: setup.redirectUri, isDefault: current.redirectUris.length === 0 }, + ], + }; + const validated = await request<{ setRedirectUris: { __typename: string } }>('setRedirectUris', { + input: { ...input, dryRun: true }, + }); + if (validated.setRedirectUris.__typename !== 'RedirectUrisSet') + return pending('Callback URL validation failed. Read back all settings in the dashboard.'); + if (JSON.stringify(await readApplication()) !== JSON.stringify(current)) + return pending('Application settings changed during setup. Recheck them before applying changes.'); + const saved = await request<{ setRedirectUris: { __typename: string } }>('setRedirectUris', { + input: { ...input, dryRun: false }, + }); + if (saved.setRedirectUris.__typename !== 'RedirectUrisSet') + return pending('Could not save the callback URL. Read back all settings in the dashboard.'); + } + } + const saved = await readApplication(); + const redirectsPreserved = original.redirectUris.every((old) => + saved.redirectUris.some((uri) => uri.uri === old.uri && (!old.isDefault || uri.isDefault)), + ); + const preserved = original.logoutUris.every((old) => + saved.logoutUris.some((uri) => uri.uri === old.uri && (!old.isDefault || uri.isDefault)), + ); + if ( + saved.id !== original.id || + !preserved || + !redirectsPreserved || + saved.logoutUris.filter((uri) => uri.isDefault).length !== 1 || + !saved.redirectUris.some((uri) => uri.uri === setup.redirectUri) || + !saved.logoutUris.some((uri) => isSignOutDestination(uri.uri) && uri.isDefault) || + saved.initiateLoginUri !== setup.initiateLoginUri || + (setup.homepageUrl !== undefined && saved.appHomepageUrl !== setup.homepageUrl) + ) { + return pending( + 'URL read-back did not match the required settings. Check the dashboard before testing authentication.', + ); + } + return { ...setup, signOutUri: saved.logoutUris.find((uri) => uri.isDefault)!.uri, verified: true }; + } catch { + // Never claim success based on a write response, expose credentials, or + // print internal API errors. A partial write requires manual read-back too. + return pending( + 'Could not verify WorkOS application settings. Check dashboard access and read back all three URLs before continuing.', + ); + } +} diff --git a/src/lib/completion-data.spec.ts b/src/lib/completion-data.spec.ts index bc049955..54fe1644 100644 --- a/src/lib/completion-data.spec.ts +++ b/src/lib/completion-data.spec.ts @@ -51,6 +51,22 @@ describe('buildCompletionData', () => { expect(data.integration).toBe('nextjs'); }); + it.each([true, false])('retains application setup status and concrete URLs when verified=%s', async (verified) => { + writePackageJson({ scripts: { dev: 'next dev' }, dependencies: { next: '16.0.0' } }); + const applicationSetup = { + clientId: 'client_app', + redirectUri: 'http://localhost:3000/callback', + signOutUri: 'http://localhost:3000/', + initiateLoginUri: 'http://localhost:3000/sign-in', + verified, + reason: verified ? undefined : 'No dashboard session is available.', + }; + const data = await buildCompletionData({ integration: 'nextjs', installDir }, { ...baseDeps, applicationSetup }); + expect(data.applicationSetup).toEqual(applicationSetup); + expect(data.nextSteps.join('\n')).toContain('Initiate login URI: http://localhost:3000/sign-in'); + expect(data.nextSteps.join('\n')).toContain(verified ? 'browser flows are not yet tested' : 'setup is incomplete'); + }); + 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 b8160655..4d7047fe 100644 --- a/src/lib/completion-data.ts +++ b/src/lib/completion-data.ts @@ -1,6 +1,20 @@ import type { CompletionData } from './events.js'; import type { DevCommandResult } from './dev-command.js'; import type { Integration } from './constants.js'; +import type { AuthkitApplicationSetup } from './authkit-application-setup.js'; + +export function applicationSetupNextSteps(setup: AuthkitApplicationSetup): string[] { + return [ + setup.verified + ? '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}`, + `Sign-out URI: ${setup.signOutUri}`, + `Initiate login URI: ${setup.initiateLoginUri} (starts sign-in; 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.', + ]; +} /** * Machine-context slice needed to build completion data. @@ -30,6 +44,7 @@ export interface CompletionDataDeps { * Resolved by the caller, which owns the config lookup. */ claimCommand?: string; + applicationSetup?: AuthkitApplicationSetup; } /** @@ -68,7 +83,13 @@ export async function buildCompletionData(ctx: CompletionContext, deps: Completi devCommand, url, files, - nextSteps: [...claim, ...concrete, ...framework], + nextSteps: [ + ...claim, + ...(deps.applicationSetup ? applicationSetupNextSteps(deps.applicationSetup) : []), + ...concrete, + ...framework, + ], + ...(deps.applicationSetup ? { applicationSetup: deps.applicationSetup } : {}), docsUrl: deps.docsUrl, dashboardUrl: deps.dashboardUrl, signInSnippet: deps.signInSnippet, diff --git a/src/lib/events.ts b/src/lib/events.ts index 32d2d77e..1dec90f2 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -25,6 +25,8 @@ export interface CompletionData { dashboardUrl: string; /** Optional per-framework "add a sign-in link" snippet */ signInSnippet?: string; + /** Saved application configuration is separate from untested browser flows. */ + applicationSetup?: import('./authkit-application-setup.js').AuthkitApplicationSetup; } export interface InstallerEvents { diff --git a/src/lib/installer-core.spec.ts b/src/lib/installer-core.spec.ts index fd05033f..dfb71214 100644 --- a/src/lib/installer-core.spec.ts +++ b/src/lib/installer-core.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { createActor, fromPromise } from 'xstate'; +import { createActor, fromPromise, waitFor } from 'xstate'; import { installerMachine } from './installer-core.js'; import { createInstallerEventEmitter } from './events.js'; import type { InstallerOptions } from '../utils/types.js'; @@ -309,6 +309,32 @@ describe('InstallerCore State Machine', () => { }); describe('full flow', () => { + it('retains pending application setup for the completion actor', async () => { + const applicationSetup = { + clientId: 'client_123', + redirectUri: 'http://localhost:3000/callback', + signOutUri: 'http://localhost:3000/', + initiateLoginUri: 'http://localhost:3000/sign-in', + verified: false, + reason: 'No dashboard session.', + }; + const { actor } = createTestActor( + { skipAuth: true, noCommit: true, apiKey: 'sk_test_123', clientId: 'client_123' }, + { + runAgent: fromPromise(async () => ({ + success: true, + summary: 'App code installed; setup pending.', + applicationSetup, + })), + }, + ); + actor.start(); + actor.send({ type: 'START' }); + await waitFor(actor, (snapshot) => snapshot.context.applicationSetup !== undefined); + expect(actor.getSnapshot().context.applicationSetup).toEqual(applicationSetup); + actor.stop(); + }); + it('completes the full wizard flow with provided credentials', async () => { const emitter = createInstallerEventEmitter(); const options: InstallerOptions = { diff --git a/src/lib/installer-core.ts b/src/lib/installer-core.ts index abf8b2a3..2d85a9d5 100644 --- a/src/lib/installer-core.ts +++ b/src/lib/installer-core.ts @@ -1016,6 +1016,7 @@ export const installerMachine = setup({ const output = event.output as AgentOutput; return output.summary; }, + applicationSetup: ({ event }) => (event.output as AgentOutput).applicationSetup, }), ({ context, event }) => { const output = event.output as AgentOutput; diff --git a/src/lib/installer-core.types.ts b/src/lib/installer-core.types.ts index 46aec300..f8270cfe 100644 --- a/src/lib/installer-core.types.ts +++ b/src/lib/installer-core.types.ts @@ -60,6 +60,7 @@ export interface InstallerMachineContext { prUrl?: string; /** Summary message from agent execution */ agentSummary?: string; + applicationSetup?: import('./authkit-application-setup.js').AuthkitApplicationSetup; /** Whether the install directory is empty and can be scaffolded into */ scaffoldable?: boolean; /** Package manager resolved for the scaffolded app */ @@ -129,6 +130,7 @@ export interface GitCheckOutput { export interface AgentOutput { success: boolean; summary?: string; + applicationSetup?: import('./authkit-application-setup.js').AuthkitApplicationSetup; error?: Error; } diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index fe9a3e44..6be960da 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -3,7 +3,9 @@ import open from 'open'; import { installerMachine } from './installer-core.js'; import { createInstallerEventEmitter } from './events.js'; import type { CompletionData } from './events.js'; -import { buildCompletionData } from './completion-data.js'; +import { buildCompletionData, applicationSetupNextSteps } from './completion-data.js'; +import { readNextjsApplicationSetup, configureAuthkitApplication } from './authkit-application-setup.js'; +import { validateInstallation } from './validation/index.js'; import { resolveDevCommand } from './dev-command.js'; import { getConfig as getInstallerSettings } from './settings.js'; import { CLIAdapter } from './adapters/cli-adapter.js'; @@ -47,7 +49,7 @@ import { generateCommitMessage as generateCommitMessageAi, generatePrDescription as generatePrDescriptionAi, } from './ai-content.js'; -import { autoConfigureWorkOSEnvironment } from './workos-management.js'; +import { autoConfigureWorkOSEnvironment, configureCallbackUri } from './workos-management.js'; import { detectPort, getCallbackPath } from './port-detection.js'; import { writeEnvLocal } from './env-writer.js'; import { getRegistry } from './registry.js'; @@ -325,10 +327,16 @@ export async function runWithCore(options: InstallerOptions): Promise { const requiresApiKey = ['nextjs', 'tanstack-start', 'react-router'].includes(integration); if (credentials.apiKey && requiresApiKey) { - await autoConfigureWorkOSEnvironment(credentials.apiKey, integration, port, { - homepageUrl: installerOptions.homepageUrl, - redirectUri: installerOptions.redirectUri, - }); + if (integration === 'nextjs') { + // Preserve API-key-only onboarding; the remaining URLs are handled + // after the agent with dashboard-session targeting and read-back. + await configureCallbackUri(credentials.apiKey, redirectUri); + } else { + await autoConfigureWorkOSEnvironment(credentials.apiKey, integration, port, { + homepageUrl: installerOptions.homepageUrl, + redirectUri: installerOptions.redirectUri, + }); + } } const redirectUriKey = integration === 'nextjs' ? 'NEXT_PUBLIC_WORKOS_REDIRECT_URI' : 'WORKOS_REDIRECT_URI'; @@ -357,9 +365,40 @@ export async function runWithCore(options: InstallerOptions): Promise { emitter: context.emitter, }; const summary = await runIntegrationInstallerFn(integration, agentOptions); + let applicationSetup; + if (integration === 'nextjs') { + applicationSetup = await readNextjsApplicationSetup( + installerOptions.installDir, + installerOptions.homepageUrl, + ); + const expectedRedirectUri = + installerOptions.redirectUri || + `http://localhost:${detectPort(integration, installerOptions.installDir)}${getCallbackPath(integration)}`; + if (applicationSetup.redirectUri !== expectedRedirectUri) { + throw new Error( + 'The app callback URL changed during installation. Confirm it before configuring WorkOS.', + ); + } + // Even --no-validate must not point the dashboard at a missing route. + const validation = await validateInstallation(integration, installerOptions.installDir, { + runBuild: false, + }); + if (!validation.passed) { + throw new Error( + `Application setup is incomplete:\n${validation.issues + .filter((issue) => issue.severity === 'error') + .map((issue) => `${issue.message}. ${issue.hint ?? ''}`) + .join('\n')}`, + ); + } + applicationSetup = await configureAuthkitApplication(applicationSetup, credentials?.clientId ?? ''); + } return { success: true, - summary: summary || `Successfully installed WorkOS AuthKit for ${integration}!`, + applicationSetup, + summary: applicationSetup + ? ['App code installed.', ...applicationSetupNextSteps(applicationSetup)].join('\n') + : summary || `Successfully installed WorkOS AuthKit for ${integration}!`, }; } catch (error) { return { @@ -371,7 +410,7 @@ export async function runWithCore(options: InstallerOptions): Promise { buildCompletion: fromPromise( async ({ input }) => { - const { integration, changedFiles, options: installerOptions, credentials } = input.context; + const { integration, changedFiles, options: installerOptions, credentials, applicationSetup } = input.context; if (!integration) return undefined; try { const registry = await getRegistry(); @@ -403,6 +442,7 @@ export async function runWithCore(options: InstallerOptions): Promise { frameworkNextSteps: cfg?.ui.getOutroNextSteps?.({}) ?? [], signInSnippet: cfg?.ui.getSignInSnippet?.({}), claimCommand: usedUnclaimedEnv ? formatWorkOSCommand('profile claim') : undefined, + applicationSetup, }, ); } catch { diff --git a/src/lib/validation/rules/nextjs.json b/src/lib/validation/rules/nextjs.json index f46e41d9..bcf47995 100644 --- a/src/lib/validation/rules/nextjs.json +++ b/src/lib/validation/rules/nextjs.json @@ -12,6 +12,11 @@ "path": "{,src/}app/**/callback/**/route.{ts,tsx,js,jsx}", "mustContain": ["handleAuth", "@workos-inc/authkit-nextjs"] }, + { + "path": "{,src/}app/sign-in/route.{ts,tsx,js,jsx}", + "mustContain": ["getSignInUrl", "@workos-inc/authkit-nextjs", "redirect", "GET"], + "severity": "error" + }, { "path": "{,src/}{middleware,proxy}.{ts,js}", "mustContainAny": ["authkitMiddleware", "authkit"] diff --git a/src/lib/validation/types.ts b/src/lib/validation/types.ts index 25a5ea56..b513bf0b 100644 --- a/src/lib/validation/types.ts +++ b/src/lib/validation/types.ts @@ -31,6 +31,7 @@ export interface FileRule { path: string; // glob pattern, e.g., "middleware.ts" or "app/**/callback/**/route.ts" mustContain?: string[]; // strings that must appear in file mustContainAny?: string[]; // at least one must appear + severity?: ValidationSeverity; // missing content patterns default to warning } export interface VariantRules { diff --git a/src/lib/validation/validator.spec.ts b/src/lib/validation/validator.spec.ts index 7c1fef7b..de538309 100644 --- a/src/lib/validation/validator.spec.ts +++ b/src/lib/validation/validator.spec.ts @@ -155,6 +155,46 @@ describe('validateInstallation', () => { }); describe('file validation', () => { + it('requires an initiate-login route distinct from the callback', async () => { + mkdirSync(join(testDir, 'app', 'callback'), { recursive: true }); + writeFileSync( + join(testDir, 'app', 'callback', 'route.ts'), + "import { handleAuth } from '@workos-inc/authkit-nextjs'; export const GET = handleAuth();", + ); + const result = await validateInstallation('nextjs', testDir, { runBuild: false }); + expect(result.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'file', severity: 'error', message: expect.stringContaining('sign-in') }), + ]), + ); + }); + + it('rejects a callback handler masquerading as the sign-in route', async () => { + mkdirSync(join(testDir, 'app', 'sign-in'), { recursive: true }); + writeFileSync( + join(testDir, 'app', 'sign-in', 'route.ts'), + "import { handleAuth } from '@workos-inc/authkit-nextjs'; export const GET = handleAuth();", + ); + const result = await validateInstallation('nextjs', testDir, { runBuild: false }); + expect(result.issues.some((issue) => issue.message.includes('getSignInUrl') && issue.severity === 'error')).toBe( + true, + ); + }); + + it.each(['app', 'src/app'])('accepts the SDK-backed sign-in route under %s', async (appDir) => { + mkdirSync(join(testDir, appDir, 'sign-in'), { recursive: true }); + writeFileSync( + join(testDir, appDir, 'sign-in', 'route.ts'), + ` + import { getSignInUrl } from '@workos-inc/authkit-nextjs'; + import { redirect } from 'next/navigation'; + export async function GET() { return redirect(await getSignInUrl()); } + `, + ); + const result = await validateInstallation('nextjs', testDir, { runBuild: false }); + expect(result.issues.filter((issue) => issue.message.includes('sign-in'))).toEqual([]); + }); + it('detects missing callback route file', async () => { writeFileSync( join(testDir, 'package.json'), @@ -291,6 +331,15 @@ describe('validateInstallation', () => { writeFileSync(join(testDir, 'middleware.ts'), 'export const authkitMiddleware = () => {};'); writeFileSync(join(testDir, 'app', 'layout.tsx'), ''); + mkdirSync(join(testDir, 'app', 'sign-in'), { recursive: true }); + writeFileSync( + join(testDir, 'app', 'sign-in', 'route.ts'), + ` + import { getSignInUrl } from '@workos-inc/authkit-nextjs'; + import { redirect } from 'next/navigation'; + export async function GET() { return redirect(await getSignInUrl()); } + `, + ); const result = await validateInstallation('nextjs', testDir); // All required files exist with required patterns, should pass diff --git a/src/lib/validation/validator.ts b/src/lib/validation/validator.ts index b6a7a06a..ccf0b14c 100644 --- a/src/lib/validation/validator.ts +++ b/src/lib/validation/validator.ts @@ -200,7 +200,7 @@ export async function validateFiles(rules: ValidationRules, projectDir: string): if (!content.includes(pattern)) { issues.push({ type: 'pattern', - severity: 'warning', + severity: rule.severity ?? 'warning', message: `File ${matches[0]} missing expected pattern: "${pattern}"`, hint: `Ensure ${matches[0]} contains: ${pattern}`, }); @@ -214,7 +214,7 @@ export async function validateFiles(rules: ValidationRules, projectDir: string): if (!hasAny) { issues.push({ type: 'pattern', - severity: 'warning', + severity: rule.severity ?? 'warning', message: `File ${matches[0]} missing one of: ${rule.mustContainAny.join(', ')}`, hint: `Ensure ${matches[0]} contains one of these patterns`, }); diff --git a/src/lib/workos-management.spec.ts b/src/lib/workos-management.spec.ts index dd371342..b0390c00 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, configureCallbackUri } = await import('./workos-management.js'); const API_KEY = 'sk_test_123'; const HOMEPAGE_ENDPOINT = 'https://api.workos.com/user_management/app_homepage_url'; @@ -115,6 +115,37 @@ describe('workos-management', () => { vi.restoreAllMocks(); }); + describe('Next.js callback-only setup', () => { + it('preserves API-key onboarding without writing homepage or CORS settings', async () => { + const { calls } = stubFetch(() => { + throw new Error('Homepage must not be read or written'); + }); + expect(await configureCallbackUri(API_KEY, `${BASE_URL}/callback`)).toBe(true); + expect(calls).toEqual([{ method: 'POST', url: 'https://api.workos.com/user_management/redirect_uris' }]); + expect(rowFor('Redirect URI').value).toBe(`${BASE_URL}/callback`); + }); + + it('treats an already registered callback as a no-op', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => jsonResponse(422, { message: 'Redirect URI already exists' })), + ); + expect(await configureCallbackUri(API_KEY, `${BASE_URL}/callback`)).toBe(true); + expect(rowFor('Redirect URI').status).toBe('already set'); + }); + + it('reports a registration failure without claiming all settings are configured', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => jsonResponse(403, { message: 'Forbidden' })), + ); + expect(await configureCallbackUri(API_KEY, `${BASE_URL}/callback`)).toBe(false); + expect(ui.log.success).not.toHaveBeenCalled(); + expect(ui.rows).not.toHaveBeenCalled(); + expect(ui.log.warn).toHaveBeenCalled(); + }); + }); + describe('setHomepageUrl read-then-write', () => { it('skips the PUT when the current homepage URL already matches', async () => { const { calls } = stubFetch(() => jsonResponse(200, { url: BASE_URL })); diff --git a/src/lib/workos-management.ts b/src/lib/workos-management.ts index c335ac7f..0b64f201 100644 --- a/src/lib/workos-management.ts +++ b/src/lib/workos-management.ts @@ -78,6 +78,25 @@ async function createRedirectUri(apiKey: string, uri: string): Promise<{ success throw new Error(error.message || `HTTP ${error.status}`); } +/** Register only the callback for Next.js, including unclaimed/API-key-only installs. */ +export async function configureCallbackUri(apiKey: string, uri: string): Promise { + try { + const result = await createRedirectUri(apiKey, uri); + ui.rows([ + { + key: 'Redirect URI', + value: uri, + status: result.alreadyExists ? 'already set' : 'created', + statusKind: result.alreadyExists ? 'muted' : 'ok', + }, + ]); + return true; + } catch { + ui.log.warn('Could not register the callback URL. Application setup will report any remaining configuration.'); + return false; + } +} + /** * Create a CORS origin in WorkOS. * Returns success on 201 or 409 (already exists). diff --git a/src/utils/summary-box.spec.ts b/src/utils/summary-box.spec.ts index a496f8d6..81512e54 100644 --- a/src/utils/summary-box.spec.ts +++ b/src/utils/summary-box.spec.ts @@ -173,6 +173,32 @@ describe('summary-box', () => { expect(result).toContain('Start dev server to test authentication'); }); + it('labels pending dashboard configuration rather than claiming complete setup', () => { + const result = strip( + renderCompletionSummary( + true, + undefined, + makeCompletion({ + applicationSetup: { + clientId: 'client_app', + redirectUri: 'http://localhost:8080/callback', + signOutUri: 'http://localhost:8080/', + initiateLoginUri: 'http://localhost:8080/sign-in', + verified: false, + }, + nextSteps: ['Configure Initiate login URI: http://localhost:8080/sign-in'], + }), + ), + ); + expect(result).toContain('App code installed; WorkOS setup required'); + expect(result).toContain('http://localhost:8080/sign-in'); + }); + + it('retains manual setup instructions when structured completion data is unavailable', () => { + const result = strip(renderCompletionSummary(true, 'Application setup is incomplete: configure /sign-in')); + expect(result).toContain('Application setup is incomplete: configure /sign-in'); + }); + it('renders the failure summary', () => { const result = strip(renderCompletionSummary(false, 'Something went wrong')); diff --git a/src/utils/summary-box.ts b/src/utils/summary-box.ts index cc38c112..2cfd6dad 100644 --- a/src/utils/summary-box.ts +++ b/src/utils/summary-box.ts @@ -19,7 +19,10 @@ export function renderCompletionSummary(success: boolean, summary?: string, comp const steps: SummaryBoxItem[] = completion.nextSteps.map((s) => ({ type: 'pending', text: s })); return renderFlatSummary({ expression: 'success', - title: 'WorkOS AuthKit Installed', + title: + completion.applicationSetup && !completion.applicationSetup.verified + ? 'App code installed; WorkOS setup required' + : 'WorkOS AuthKit Installed', items: [...shown, ...steps], footer: completion.docsUrl, }); @@ -29,6 +32,7 @@ export function renderCompletionSummary(success: boolean, summary?: string, comp expression: 'success', title: 'WorkOS AuthKit Installed', items: [ + ...(summary ? [{ type: 'pending' as const, text: summary }] : []), { type: 'pending', text: 'Start dev server to test authentication' }, { type: 'pending', text: 'Visit WorkOS Dashboard to manage users' }, ],