diff --git a/src/integrations/dotnet/index.ts b/src/integrations/dotnet/index.ts index 4f1bf5d4..28ce57f4 100644 --- a/src/integrations/dotnet/index.ts +++ b/src/integrations/dotnet/index.ts @@ -91,16 +91,27 @@ export async function run(options: InstallerOptions): Promise { integration: config.metadata.integration, }); - const { apiKey, clientId } = await getOrAskForWorkOSCredentials(options, config.environment.requiresApiKey); + // apiKey/clientId are mutable: dashboard-config 401 recovery may swap in a + // fresh credential pair from a different environment. + const { apiKey: initialApiKey, clientId: initialClientId } = await getOrAskForWorkOSCredentials( + options, + config.environment.requiresApiKey, + ); + let apiKey = initialApiKey; + let clientId = initialClientId; // Auto-configure WorkOS environment (redirect URI, CORS, homepage) const callerHandledConfig = Boolean(options.apiKey || options.clientId); if (!callerHandledConfig && apiKey) { const port = 5000; // ASP.NET Core default HTTP port - await autoConfigureWorkOSEnvironment(apiKey, config.metadata.integration, port, { + const outcome = await autoConfigureWorkOSEnvironment(apiKey, config.metadata.integration, port, { homepageUrl: options.homepageUrl, redirectUri: options.redirectUri, }); + if (outcome) { + apiKey = outcome.apiKey; + if (outcome.clientId) clientId = outcome.clientId; + } } // Build prompt — credentials are passed via prompt context since .NET doesn't use .env.local diff --git a/src/integrations/go/index.ts b/src/integrations/go/index.ts index efc9f8a1..8dc68232 100644 --- a/src/integrations/go/index.ts +++ b/src/integrations/go/index.ts @@ -126,16 +126,27 @@ export async function run(options: InstallerOptions): Promise { }); // Get WorkOS credentials - const { apiKey, clientId } = await getOrAskForWorkOSCredentials(options, config.environment.requiresApiKey); + // apiKey/clientId are mutable: dashboard-config 401 recovery may swap in a + // fresh credential pair from a different environment. + const { apiKey: initialApiKey, clientId: initialClientId } = await getOrAskForWorkOSCredentials( + options, + config.environment.requiresApiKey, + ); + let apiKey = initialApiKey; + let clientId = initialClientId; // Auto-configure WorkOS environment (redirect URI, CORS) const callerHandledConfig = Boolean(options.apiKey || options.clientId); if (!callerHandledConfig && apiKey) { const redirectUri = options.redirectUri || `http://localhost:${GO_DEFAULT_PORT}${GO_CALLBACK_PATH}`; - await autoConfigureWorkOSEnvironment(apiKey, config.metadata.integration, GO_DEFAULT_PORT, { + const outcome = await autoConfigureWorkOSEnvironment(apiKey, config.metadata.integration, GO_DEFAULT_PORT, { homepageUrl: options.homepageUrl, redirectUri, }); + if (outcome) { + apiKey = outcome.apiKey; + if (outcome.clientId) clientId = outcome.clientId; + } } // Gather Go-specific context diff --git a/src/integrations/ruby/index.ts b/src/integrations/ruby/index.ts index 033c2010..84aafe3a 100644 --- a/src/integrations/ruby/index.ts +++ b/src/integrations/ruby/index.ts @@ -78,19 +78,27 @@ export async function run(options: InstallerOptions): Promise { }); // Get WorkOS credentials - const { apiKey, clientId: _clientId } = await getOrAskForWorkOSCredentials( + // apiKey/clientId are mutable: dashboard-config 401 recovery may swap in a + // fresh credential pair from a different environment. + const { apiKey: initialApiKey, clientId: initialClientId } = await getOrAskForWorkOSCredentials( options, config.environment.requiresApiKey, ); + let apiKey = initialApiKey; + let clientId = initialClientId; // Auto-configure WorkOS environment (redirect URI, CORS, homepage) if not already done const callerHandledConfig = Boolean(options.apiKey || options.clientId); if (!callerHandledConfig && apiKey) { const port = 3000; // Rails default - await autoConfigureWorkOSEnvironment(apiKey, config.metadata.integration, port, { + const outcome = await autoConfigureWorkOSEnvironment(apiKey, config.metadata.integration, port, { homepageUrl: options.homepageUrl, redirectUri: options.redirectUri, }); + if (outcome) { + apiKey = outcome.apiKey; + if (outcome.clientId) clientId = outcome.clientId; + } } // Build prompt for the agent @@ -107,7 +115,7 @@ export async function run(options: InstallerOptions): Promise { The following environment variables are needed (create a .env file if one does not exist): - WORKOS_API_KEY -- WORKOS_CLIENT_ID +- WORKOS_CLIENT_ID=${clientId} - WORKOS_REDIRECT_URI=${redirectUri} ## Integration Instructions diff --git a/src/lib/agent-runner.ts b/src/lib/agent-runner.ts index bcbf95c4..0173e17c 100644 --- a/src/lib/agent-runner.ts +++ b/src/lib/agent-runner.ts @@ -55,8 +55,15 @@ export async function runAgentInstaller(config: FrameworkConfig, options: Instal integration: config.metadata.integration, }); - // Get WorkOS credentials (API key optional for client-only SDKs) - const { apiKey, clientId } = await getOrAskForWorkOSCredentials(options, config.environment.requiresApiKey); + // Get WorkOS credentials (API key optional for client-only SDKs). + // apiKey/clientId are mutable: dashboard-config 401 recovery may swap in a + // fresh credential pair from a different environment. + const { apiKey: initialApiKey, clientId: initialClientId } = await getOrAskForWorkOSCredentials( + options, + config.environment.requiresApiKey, + ); + let apiKey = initialApiKey; + let clientId = initialClientId; // Check if caller (state machine) already configured WorkOS environment // If credentials were passed via options, the caller handled config+env writing @@ -66,10 +73,17 @@ export async function runAgentInstaller(config: FrameworkConfig, options: Instal // Skip if caller already handled this (prevents duplicate dashboard config output) if (!callerHandledConfig && apiKey && config.environment.requiresApiKey) { const port = detectPort(config.metadata.integration, options.installDir); - await autoConfigureWorkOSEnvironment(apiKey, config.metadata.integration, port, { + const outcome = await autoConfigureWorkOSEnvironment(apiKey, config.metadata.integration, port, { homepageUrl: options.homepageUrl, redirectUri: options.redirectUri, }); + // If 401 recovery re-authenticated, continue with the credentials that + // worked. Use the recovered clientId when present — the new key may + // belong to a different environment than the original client ID. + if (outcome) { + apiKey = outcome.apiKey; + if (outcome.clientId) clientId = outcome.clientId; + } } // Gather framework-specific context (e.g., Next.js router, React Native platform) diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index 1d8e2f3c..5a872f29 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -320,18 +320,32 @@ export async function runWithCore(options: InstallerOptions): Promise { const redirectUri = installerOptions.redirectUri || `http://localhost:${port}${callbackPath}`; const requiresApiKey = ['nextjs', 'tanstack-start', 'react-router'].includes(integration); - if (credentials.apiKey && requiresApiKey) { - await autoConfigureWorkOSEnvironment(credentials.apiKey, integration, port, { + // Mutable: dashboard-config 401 recovery may swap in a fresh credential pair. + let apiKey = credentials.apiKey; + let clientId = credentials.clientId; + if (apiKey && requiresApiKey) { + const outcome = await autoConfigureWorkOSEnvironment(apiKey, integration, port, { homepageUrl: installerOptions.homepageUrl, redirectUri: installerOptions.redirectUri, }); + // If 401 recovery re-authenticated, use the credentials that worked — + // the recovered key may belong to a different environment than the + // original client ID, so adopt the recovered clientId when present. + if (outcome) { + apiKey = outcome.apiKey; + if (outcome.clientId) clientId = outcome.clientId; + // Write back to the shared machine context so the later runAgent + // step hands the agent the working credentials, not the rejected key. + credentials.apiKey = apiKey; + credentials.clientId = clientId; + } } const redirectUriKey = integration === 'nextjs' ? 'NEXT_PUBLIC_WORKOS_REDIRECT_URI' : 'WORKOS_REDIRECT_URI'; writeEnvLocal(installerOptions.installDir, { - ...(credentials.apiKey ? { WORKOS_API_KEY: credentials.apiKey } : {}), - WORKOS_CLIENT_ID: credentials.clientId, + ...(apiKey ? { WORKOS_API_KEY: apiKey } : {}), + WORKOS_CLIENT_ID: clientId, [redirectUriKey]: redirectUri, }); }), diff --git a/src/lib/workos-management.spec.ts b/src/lib/workos-management.spec.ts new file mode 100644 index 00000000..beb4fc14 --- /dev/null +++ b/src/lib/workos-management.spec.ts @@ -0,0 +1,316 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const mockCapture = vi.fn(); +vi.mock('../utils/analytics.js', () => ({ + analytics: { capture: (...args: unknown[]) => mockCapture(...args) }, +})); + +const mockSelect = vi.fn(); +const mockPassword = vi.fn(); +const mockUi = { + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + step: vi.fn(), + success: vi.fn(), + hint: vi.fn(), + detail: vi.fn(), + }, + rows: vi.fn(), + select: (...args: unknown[]) => mockSelect(...args), + password: (...args: unknown[]) => mockPassword(...args), +}; +vi.mock('../utils/ui.js', () => ({ + default: mockUi, + isCancel: (v: unknown) => typeof v === 'symbol', + isDashboardMode: () => false, +})); + +const mockIsPromptAllowed = vi.fn(() => false); +vi.mock('../utils/interaction-mode.js', () => ({ + isPromptAllowed: () => mockIsPromptAllowed(), +})); + +vi.mock('./port-detection.js', () => ({ + getCallbackPath: () => '/callback', +})); + +// Mocks for the re-auth path's dynamic imports +const mockEnsureAuthenticated = vi.fn(); +vi.mock('./ensure-auth.js', () => ({ + ensureAuthenticated: () => mockEnsureAuthenticated(), +})); +const mockGetAccessToken = vi.fn(); +const mockSaveStagingCredentials = vi.fn(); +vi.mock('./credentials.js', () => ({ + getAccessToken: () => mockGetAccessToken(), + saveStagingCredentials: (...args: unknown[]) => mockSaveStagingCredentials(...args), +})); +const mockFetchStagingCredentials = vi.fn(); +vi.mock('./staging-api.js', () => ({ + fetchStagingCredentials: (...args: unknown[]) => mockFetchStagingCredentials(...args), +})); + +const { autoConfigureWorkOSEnvironment, promptForUnauthorizedRecovery, DashboardConfigError } = await import( + './workos-management.js' +); + +function mockResponse(status: number, body?: unknown) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as Response; +} + +const OK = () => mockResponse(200, {}); +const UNAUTHORIZED = () => mockResponse(401, { message: 'Unauthorized' }); + +describe('workos-management', () => { + const mockFetch = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockIsPromptAllowed.mockReturnValue(false); + vi.stubGlobal('fetch', mockFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe('autoConfigureWorkOSEnvironment', () => { + it('returns the config results and original API key on success', async () => { + mockFetch.mockResolvedValue(OK()); + + const outcome = await autoConfigureWorkOSEnvironment('sk_test_123', 'nextjs', 3000); + + expect(outcome).not.toBeNull(); + expect(outcome!.apiKey).toBe('sk_test_123'); + // No recovery happened, so no replacement clientId is surfaced + expect(outcome!.clientId).toBeUndefined(); + expect(outcome!.results.homepageUrl).toEqual({ success: true }); + expect(mockFetch).toHaveBeenCalledTimes(3); + expect(mockUi.log.success).toHaveBeenCalledWith('WorkOS dashboard configured'); + }); + + it('treats 409/422 already-exists as success', async () => { + mockFetch + .mockResolvedValueOnce(mockResponse(422, { message: 'redirect uri already exists' })) + .mockResolvedValueOnce(mockResponse(409, { message: 'already exists' })) + .mockResolvedValueOnce(OK()); + + const outcome = await autoConfigureWorkOSEnvironment('sk_test_123', 'nextjs', 3000); + + expect(outcome).not.toBeNull(); + expect(outcome!.results.redirectUri).toEqual({ success: true, alreadyExists: true }); + expect(outcome!.results.corsOrigin).toEqual({ success: true, alreadyExists: true }); + }); + + it('retries with the recovered key after a 401 (re-auth → retry)', async () => { + // First round: all three calls 401 (Promise.all rejects on the first) + mockFetch.mockResolvedValueOnce(UNAUTHORIZED()).mockResolvedValueOnce(UNAUTHORIZED()).mockResolvedValueOnce(OK()); + // Second round: everything succeeds with the fresh key + mockFetch.mockResolvedValue(OK()); + const onUnauthorized = vi.fn().mockResolvedValue({ apiKey: 'sk_fresh' }); + + const outcome = await autoConfigureWorkOSEnvironment('sk_stale', 'nextjs', 3000, { onUnauthorized }); + + expect(onUnauthorized).toHaveBeenCalledTimes(1); + expect(onUnauthorized).toHaveBeenCalledWith(1); + expect(outcome).not.toBeNull(); + expect(outcome!.apiKey).toBe('sk_fresh'); + // Pasted-key style recovery carries no clientId + expect(outcome!.clientId).toBeUndefined(); + // The retry used the fresh key + const lastCall = mockFetch.mock.calls.at(-1)!; + expect(lastCall[1].headers.Authorization).toBe('Bearer sk_fresh'); + }); + + it('propagates the paired clientId when re-auth recovers into a different environment', async () => { + // First round 401s; retry succeeds with credentials from a DIFFERENT environment + mockFetch.mockResolvedValueOnce(UNAUTHORIZED()).mockResolvedValueOnce(UNAUTHORIZED()).mockResolvedValueOnce(OK()); + mockFetch.mockResolvedValue(OK()); + const onUnauthorized = vi.fn().mockResolvedValue({ apiKey: 'sk_other_env', clientId: 'client_other_env' }); + + const outcome = await autoConfigureWorkOSEnvironment('sk_stale', 'nextjs', 3000, { onUnauthorized }); + + expect(outcome).not.toBeNull(); + expect(outcome!.apiKey).toBe('sk_other_env'); + // The clientId paired with the new key must surface so callers don't + // mix the new key with the original environment's client ID. + expect(outcome!.clientId).toBe('client_other_env'); + const lastCall = mockFetch.mock.calls.at(-1)!; + expect(lastCall[1].headers.Authorization).toBe('Bearer sk_other_env'); + }); + + it('falls back to specific manual instructions when the user declines recovery', async () => { + mockFetch.mockResolvedValue(UNAUTHORIZED()); + const onUnauthorized = vi.fn().mockResolvedValue(null); + + const outcome = await autoConfigureWorkOSEnvironment('sk_stale', 'nextjs', 3000, { onUnauthorized }); + + expect(outcome).toBeNull(); + expect(onUnauthorized).toHaveBeenCalledTimes(1); + // One round of API calls only — no retry after decline + expect(mockFetch).toHaveBeenCalledTimes(3); + // Manual instructions are specific: values + where to set them + const manualLine = mockUi.log.info.mock.calls.map((c) => String(c[0])).find((m) => m.includes('manually')); + expect(manualLine).toContain('https://dashboard.workos.com'); + const rows = mockUi.rows.mock.calls.at(-1)![0] as Array<{ key: string; value: string }>; + expect(rows.map((r) => r.value)).toEqual([ + 'http://localhost:3000/callback', + 'http://localhost:3000', + 'http://localhost:3000', + ]); + }); + + it('keeps recovering when a retry 401s again, then falls back after decline', async () => { + mockFetch.mockResolvedValue(UNAUTHORIZED()); + const onUnauthorized = vi + .fn() + .mockResolvedValueOnce({ apiKey: 'sk_fresh' }) // first recovery: retry + .mockResolvedValueOnce(null); // second prompt: decline + + const outcome = await autoConfigureWorkOSEnvironment('sk_stale', 'nextjs', 3000, { onUnauthorized }); + + expect(outcome).toBeNull(); + expect(onUnauthorized).toHaveBeenCalledTimes(2); + // Two rounds of API calls (initial + one retry), then stopped + expect(mockFetch).toHaveBeenCalledTimes(6); + }); + + it('bounds recovery loops (no infinite retries when every key 401s)', async () => { + mockFetch.mockResolvedValue(UNAUTHORIZED()); + // Distinct key per recovery so the same-key guard doesn't short-circuit. + const onUnauthorized = vi + .fn() + .mockResolvedValueOnce({ apiKey: 'sk_new_1' }) + .mockResolvedValueOnce({ apiKey: 'sk_new_2' }); + + const outcome = await autoConfigureWorkOSEnvironment('sk_stale', 'nextjs', 3000, { onUnauthorized }); + + expect(outcome).toBeNull(); + // MAX_UNAUTHORIZED_RECOVERIES = 2 → 2 prompts, 3 total API rounds + expect(onUnauthorized).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledTimes(9); + }); + + it('does not retry when recovery returns the same key', async () => { + mockFetch.mockResolvedValue(UNAUTHORIZED()); + const onUnauthorized = vi.fn().mockResolvedValue({ apiKey: 'sk_stale' }); + + const outcome = await autoConfigureWorkOSEnvironment('sk_stale', 'nextjs', 3000, { onUnauthorized }); + + expect(outcome).toBeNull(); + expect(onUnauthorized).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + it('falls back to manual instructions when the recovery hook throws', async () => { + mockFetch.mockResolvedValue(UNAUTHORIZED()); + const onUnauthorized = vi.fn().mockRejectedValue(new Error('browser exploded')); + + const outcome = await autoConfigureWorkOSEnvironment('sk_stale', 'nextjs', 3000, { onUnauthorized }); + + expect(outcome).toBeNull(); + expect(onUnauthorized).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + it('does not attempt recovery for non-401 errors', async () => { + mockFetch.mockResolvedValue(mockResponse(500, { message: 'Internal Server Error' })); + const onUnauthorized = vi.fn().mockResolvedValue({ apiKey: 'sk_fresh' }); + + const outcome = await autoConfigureWorkOSEnvironment('sk_test_123', 'nextjs', 3000, { onUnauthorized }); + + expect(outcome).toBeNull(); + expect(onUnauthorized).not.toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledTimes(3); + expect(mockUi.log.warn).toHaveBeenCalledWith( + expect.stringContaining('Could not configure WorkOS dashboard: Internal Server Error'), + ); + }); + + it('explains the likely cause on 401', async () => { + mockFetch.mockResolvedValue(UNAUTHORIZED()); + const onUnauthorized = vi.fn().mockResolvedValue(null); + + await autoConfigureWorkOSEnvironment('sk_stale', 'nextjs', 3000, { onUnauthorized }); + + const warnings = mockUi.log.warn.mock.calls.map((c) => String(c[0])); + expect(warnings.some((m) => m.includes('401'))).toBe(true); + const infos = mockUi.log.info.mock.calls.map((c) => String(c[0])); + expect(infos.some((m) => m.includes('expired') && m.includes('different environment'))).toBe(true); + }); + }); + + describe('promptForUnauthorizedRecovery (default 401 handler)', () => { + it('returns null when prompting is not allowed (agent/CI mode)', async () => { + mockIsPromptAllowed.mockReturnValue(false); + expect(await promptForUnauthorizedRecovery()).toBeNull(); + expect(mockSelect).not.toHaveBeenCalled(); + }); + + it('returns null when the user chooses manual setup', async () => { + mockIsPromptAllowed.mockReturnValue(true); + mockSelect.mockResolvedValue('manual'); + + expect(await promptForUnauthorizedRecovery()).toBeNull(); + }); + + it('returns null when the user cancels the prompt', async () => { + mockIsPromptAllowed.mockReturnValue(true); + mockSelect.mockResolvedValue(Symbol('cancel')); + + expect(await promptForUnauthorizedRecovery()).toBeNull(); + }); + + it('returns the entered key with no clientId (and a pairing warning) when the user pastes a new API key', async () => { + mockIsPromptAllowed.mockReturnValue(true); + mockSelect.mockResolvedValue('apikey'); + mockPassword.mockResolvedValue(' sk_pasted '); + + const recovered = await promptForUnauthorizedRecovery(); + expect(recovered).toEqual({ apiKey: 'sk_pasted' }); + expect(recovered!.clientId).toBeUndefined(); + // The pasted key's environment is unknown — the user is warned that the + // existing client ID may no longer match. + const warnings = mockUi.log.warn.mock.calls.map((c) => String(c[0])); + expect(warnings.some((m) => m.includes('client ID') && m.includes('may no longer match'))).toBe(true); + }); + + it('re-authenticates and returns fresh staging credentials', async () => { + mockIsPromptAllowed.mockReturnValue(true); + mockSelect.mockResolvedValue('reauth'); + mockEnsureAuthenticated.mockResolvedValue({ authenticated: true }); + mockGetAccessToken.mockReturnValue('oauth-token'); + mockFetchStagingCredentials.mockResolvedValue({ clientId: 'client_new', apiKey: 'sk_new' }); + + // Both credentials surface — the fresh key may belong to a different + // environment, so the paired clientId must travel with it. + expect(await promptForUnauthorizedRecovery()).toEqual({ apiKey: 'sk_new', clientId: 'client_new' }); + expect(mockFetchStagingCredentials).toHaveBeenCalledWith('oauth-token'); + expect(mockSaveStagingCredentials).toHaveBeenCalledWith({ clientId: 'client_new', apiKey: 'sk_new' }); + }); + + it('returns null when re-authentication fails', async () => { + mockIsPromptAllowed.mockReturnValue(true); + mockSelect.mockResolvedValue('reauth'); + mockEnsureAuthenticated.mockRejectedValue(new Error('device auth timed out')); + + expect(await promptForUnauthorizedRecovery()).toBeNull(); + expect(mockUi.log.warn).toHaveBeenCalledWith(expect.stringContaining('Re-authentication failed')); + }); + }); + + describe('DashboardConfigError', () => { + it('carries the HTTP status', () => { + const err = new DashboardConfigError('Unauthorized', 401); + expect(err.status).toBe(401); + expect(err.message).toBe('Unauthorized'); + expect(err).toBeInstanceOf(Error); + }); + }); +}); diff --git a/src/lib/workos-management.ts b/src/lib/workos-management.ts index be8cd6df..58aac3fa 100644 --- a/src/lib/workos-management.ts +++ b/src/lib/workos-management.ts @@ -1,17 +1,70 @@ import type { Integration } from './constants.js'; import { INSTALLER_INTERACTION_EVENT_NAME } from './constants.js'; import { analytics } from '../utils/analytics.js'; -import ui from '../utils/ui.js'; +import ui, { isCancel, isDashboardMode } from '../utils/ui.js'; +import { isPromptAllowed } from '../utils/interaction-mode.js'; +import { getConfig as getInstallerSettings } from './settings.js'; import { getCallbackPath } from './port-detection.js'; const WORKOS_API_BASE = 'https://api.workos.com'; +/** + * How many times dashboard auto-config may ask the user to recover from a + * 401 (re-auth or fresh API key) before giving up. Bounded so a persistently + * rejected key can never loop the installer — the user can always decline. + */ +const MAX_UNAUTHORIZED_RECOVERIES = 2; + export interface AutoConfigResult { redirectUri: { success: boolean; alreadyExists: boolean }; corsOrigin: { success: boolean; alreadyExists: boolean }; homepageUrl: { success: boolean }; } +export interface AutoConfigOutcome { + results: AutoConfigResult; + /** + * The API key that ultimately succeeded. Differs from the key passed in + * when 401 recovery swapped credentials — callers should write THIS key to + * env files / hand it to the agent, not the rejected one. + */ + apiKey: string; + /** + * The client ID paired with the recovered API key. Only present when 401 + * recovery re-authenticated and fetched a fresh credential pair — the new + * key may belong to a DIFFERENT environment than the original client ID, + * so callers must use this clientId when present to avoid mixing + * credentials from two environments. Undefined for pasted keys (the + * pasted key's environment is unknown). + */ + clientId?: string; +} + +/** + * Credentials returned by a 401 recovery hook. `clientId` is present only + * when recovery re-authenticated and provisioned a fresh credential pair; + * a pasted API key carries no clientId because its environment is unknown. + */ +export interface RecoveredCredentials { + apiKey: string; + clientId?: string; +} + +/** + * Dashboard configuration call failed with an HTTP status. Carries the status + * so 401 (Unauthorized) can be detected exactly instead of by string-matching + * the API's error message. + */ +export class DashboardConfigError extends Error { + constructor( + message: string, + public readonly status: number, + ) { + super(message); + this.name = 'DashboardConfigError'; + } +} + interface FetchError { status: number; message: string; @@ -48,6 +101,10 @@ async function parseFetchError(response: Response): Promise { }; } +function toDashboardConfigError(error: FetchError): DashboardConfigError { + return new DashboardConfigError(error.message || `HTTP ${error.status}`, error.status); +} + /** * Create a redirect URI in WorkOS. * Returns success on 201 or 409 (already exists). @@ -65,7 +122,7 @@ async function createRedirectUri(apiKey: string, uri: string): Promise<{ success return { success: true, alreadyExists: true }; } - throw new Error(error.message || `HTTP ${error.status}`); + throw toDashboardConfigError(error); } /** @@ -85,7 +142,7 @@ async function createCorsOrigin(apiKey: string, origin: string): Promise<{ succe return { success: true, alreadyExists: true }; } - throw new Error(error.message || `HTTP ${error.status}`); + throw toDashboardConfigError(error); } /** @@ -96,7 +153,7 @@ async function setHomepageUrl(apiKey: string, url: string): Promise<{ success: b if (!response.ok) { const error = await parseFetchError(response); - throw new Error(error.message || `HTTP ${error.status}`); + throw toDashboardConfigError(error); } return { success: true }; @@ -107,25 +164,134 @@ export interface AutoConfigOptions { homepageUrl?: string; /** Custom redirect URI (defaults to framework convention) */ redirectUri?: string; + /** + * Recovery hook invoked when the WorkOS API rejects the API key with + * 401 Unauthorized. Should re-authenticate (or collect a fresh key) and + * return the replacement credentials; return null to decline recovery. + * Invoked at most MAX_UNAUTHORIZED_RECOVERIES times per call, then the + * manual-setup instructions are shown. Defaults to an interactive prompt + * (human TTY only); pass explicitly in tests or non-standard flows. + */ + onUnauthorized?: (attempt: number) => Promise; +} + +/** + * Default 401 recovery: explain the likely cause, then offer to + * re-authenticate (fresh staging credentials via the OAuth login flow) or + * paste a different API key. Returns null when the user declines, when + * prompting isn't possible (agent/CI/JSON/dashboard modes), or when re-auth + * fails to produce a working key. + */ +export async function promptForUnauthorizedRecovery(): Promise { + if (!isPromptAllowed() || isDashboardMode()) return null; + + const choice = await ui.select<'reauth' | 'apikey' | 'manual'>({ + message: 'How would you like to proceed?', + options: [ + { + value: 'reauth', + label: 'Re-authenticate with WorkOS', + hint: 'Sign in again to get fresh credentials, then retry', + }, + { + value: 'apikey', + label: 'Enter a different API key', + hint: 'Paste a key from the WorkOS dashboard', + }, + { + value: 'manual', + label: 'Configure manually', + hint: 'Show the exact dashboard settings to set yourself', + }, + ], + }); + + if (isCancel(choice) || choice === 'manual') return null; + + if (choice === 'apikey') { + const value = await ui.password({ + message: 'Enter your WorkOS API Key', + validate: (v) => (v.trim() ? undefined : 'API Key is required'), + }); + if (isCancel(value)) return null; + // The pasted key may belong to a different environment than the client + // ID already collected — we can't know, so warn instead of guessing. + ui.log.warn( + 'If this key belongs to a different WorkOS environment, your existing client ID may no longer match — verify WORKOS_CLIENT_ID after install.', + ); + return { apiKey: value.trim() }; + } + + // Re-authenticate: refresh/login via the OAuth device flow, then pull fresh + // staging credentials — the same source the installer provisions at login. + // Dynamic imports avoid an import cycle with the auth modules. + try { + const { ensureAuthenticated } = await import('./ensure-auth.js'); + const auth = await ensureAuthenticated(); + if (!auth.authenticated) return null; + + const { getAccessToken, saveStagingCredentials } = await import('./credentials.js'); + const token = getAccessToken(); + if (!token) return null; + + const { fetchStagingCredentials } = await import('./staging-api.js'); + const staging = await fetchStagingCredentials(token); + saveStagingCredentials(staging); + ui.log.success('Re-authenticated with WorkOS'); + // Surface BOTH credentials: re-auth may have selected a different + // WorkOS account/environment, and the new API key is only valid when + // paired with the client ID from the same environment. + return { apiKey: staging.apiKey, clientId: staging.clientId }; + } catch (error) { + ui.log.warn(`Re-authentication failed: ${error instanceof Error ? error.message : String(error)}`); + return null; + } +} + +function isUnauthorized(error: unknown): boolean { + return error instanceof DashboardConfigError && error.status === 401; +} + +/** Explain why a 401 happened and what actually fixes it. */ +function explainUnauthorized(): void { + ui.log.warn('WorkOS rejected the API key (401 Unauthorized).'); + ui.log.info(' This usually means the key expired, was revoked, or belongs to a different environment.'); +} + +/** Print the exact dashboard settings the user would need to apply by hand. */ +function showManualInstructions(callbackUrl: string, baseUrl: string, homepageUrl: string): void { + const dashboardUrl = getInstallerSettings().documentation.dashboardUrl; + ui.log.info(`You can configure these settings manually in the WorkOS dashboard (${dashboardUrl}):`); + ui.rows([ + { key: 'Redirect URI', value: callbackUrl, status: 'User Management → Redirects', statusKind: 'muted' }, + { key: 'CORS origin', value: baseUrl, status: 'User Management → CORS', statusKind: 'muted' }, + { key: 'Homepage URL', value: homepageUrl, status: 'User Management → Branding', statusKind: 'muted' }, + ]); } /** * Auto-configure WorkOS dashboard settings for local development. * Sets redirect URI, CORS origin, and homepage URL via the WorkOS API. * + * On 401 Unauthorized, offers a bounded recovery path (re-authenticate or + * enter a fresh API key, then retry) before falling back to specific manual + * setup instructions. Other failures keep the previous behavior: log and + * fall back to manual setup without blocking the wizard. + * * @param apiKey - WorkOS API key (sk_xxx) * @param integration - Framework integration type * @param port - Detected or default dev server port - * @param options - Optional overrides for homepage URL and redirect URI + * @param options - Optional overrides for homepage URL, redirect URI, and 401 recovery * - * Non-blocking: failures are logged but don't stop the wizard. + * @returns The config results plus the API key that worked, or null when + * configuration failed and the user was pointed at manual setup. */ export async function autoConfigureWorkOSEnvironment( apiKey: string, integration: Integration, port: number, options: AutoConfigOptions = {}, -): Promise { +): Promise { const baseUrl = `http://localhost:${port}`; const callbackPath = getCallbackPath(integration); const callbackUrl = options.redirectUri || `${baseUrl}${callbackPath}`; @@ -133,66 +299,110 @@ export async function autoConfigureWorkOSEnvironment( ui.log.step('Configuring WorkOS dashboard settings...'); - try { - const [redirectUri, corsOrigin, homepageUrl] = await Promise.all([ - createRedirectUri(apiKey, callbackUrl), - createCorsOrigin(apiKey, baseUrl), - setHomepageUrl(apiKey, homepageUrlValue), - ]); - - const results: AutoConfigResult = { redirectUri, corsOrigin, homepageUrl }; - - analytics.capture(INSTALLER_INTERACTION_EVENT_NAME, { - action: 'workos environment auto-configured', - integration, - port, - redirectUri: redirectUri.alreadyExists ? 'existed' : 'created', - corsOrigin: corsOrigin.alreadyExists ? 'existed' : 'created', - }); + let currentApiKey = apiKey; + // Only set when recovery re-authenticated — the fresh key may belong to a + // different environment than the original client ID, so it must travel + // with its paired clientId. + let recoveredClientId: string | undefined; + let recoveryAttempts = 0; - // Aligned key/value feedback: value in accent, a dim status for "already - // existed" vs. a green status for a fresh create/update. - ui.log.success('WorkOS dashboard configured'); - ui.rows([ - { - key: 'Redirect URI', - value: callbackUrl, - status: redirectUri.alreadyExists ? 'already set' : 'created', - statusKind: redirectUri.alreadyExists ? 'muted' : 'ok', - }, - { - key: 'CORS origin', - value: baseUrl, - status: corsOrigin.alreadyExists ? 'already set' : 'created', - statusKind: corsOrigin.alreadyExists ? 'muted' : 'ok', - }, - { key: 'Homepage URL', value: homepageUrlValue, status: 'updated', statusKind: 'ok' }, - ]); + while (true) { + try { + const [redirectUri, corsOrigin, homepageUrl] = await Promise.all([ + createRedirectUri(currentApiKey, callbackUrl), + createCorsOrigin(currentApiKey, baseUrl), + setHomepageUrl(currentApiKey, homepageUrlValue), + ]); - return results; - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error'; - - // Provide specific guidance for common errors - if (message.includes('401') || message.includes('Invalid API key')) { - ui.log.warn('Could not configure WorkOS dashboard: Invalid API key'); - } else if (message.includes('403') || message.includes('permission')) { - ui.log.warn('Could not configure WorkOS dashboard: API key lacks permission'); - } else if (message.includes('422') || message.includes('Validation')) { - ui.log.warn(`Could not configure WorkOS dashboard: Validation error`); - ui.log.info(` Error: ${message}`); - } else { - ui.log.warn(`Could not configure WorkOS dashboard: ${message}`); - } + const results: AutoConfigResult = { redirectUri, corsOrigin, homepageUrl }; - ui.log.info('You can configure these settings manually in the WorkOS dashboard.'); + analytics.capture(INSTALLER_INTERACTION_EVENT_NAME, { + action: 'workos environment auto-configured', + integration, + port, + redirectUri: redirectUri.alreadyExists ? 'existed' : 'created', + corsOrigin: corsOrigin.alreadyExists ? 'existed' : 'created', + recoveredAfterUnauthorized: recoveryAttempts > 0, + }); - analytics.capture(INSTALLER_INTERACTION_EVENT_NAME, { - action: 'workos environment auto-config failed', - integration, - error: message, - }); + // Aligned key/value feedback: value in accent, a dim status for "already + // existed" vs. a green status for a fresh create/update. + ui.log.success('WorkOS dashboard configured'); + ui.rows([ + { + key: 'Redirect URI', + value: callbackUrl, + status: redirectUri.alreadyExists ? 'already set' : 'created', + statusKind: redirectUri.alreadyExists ? 'muted' : 'ok', + }, + { + key: 'CORS origin', + value: baseUrl, + status: corsOrigin.alreadyExists ? 'already set' : 'created', + statusKind: corsOrigin.alreadyExists ? 'muted' : 'ok', + }, + { key: 'Homepage URL', value: homepageUrlValue, status: 'updated', statusKind: 'ok' }, + ]); - return null; + return { results, apiKey: currentApiKey, clientId: recoveredClientId }; + } catch (error) { + // 401 — offer re-auth + retry before giving up. Bounded by + // MAX_UNAUTHORIZED_RECOVERIES; declining also ends the loop. + if (isUnauthorized(error) && recoveryAttempts < MAX_UNAUTHORIZED_RECOVERIES) { + explainUnauthorized(); + const recover = options.onUnauthorized ?? promptForUnauthorizedRecovery; + recoveryAttempts++; + + let recovered: RecoveredCredentials | null = null; + try { + recovered = await recover(recoveryAttempts); + } catch (recoveryError) { + ui.log.warn( + `Credential recovery failed: ${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`, + ); + } + + if (recovered?.apiKey && recovered.apiKey !== currentApiKey) { + analytics.capture(INSTALLER_INTERACTION_EVENT_NAME, { + action: 'workos environment auto-config retry after re-auth', + integration, + attempt: recoveryAttempts, + }); + ui.log.step('Retrying WorkOS dashboard configuration...'); + currentApiKey = recovered.apiKey; + recoveredClientId = recovered.clientId; + continue; + } + // Declined (or recovery produced no new key) → manual instructions below. + } + + const message = error instanceof Error ? error.message : 'Unknown error'; + const status = error instanceof DashboardConfigError ? error.status : undefined; + + // Provide specific guidance for common errors + if (status === 401) { + // Recovery was declined or exhausted above — the cause was already explained. + ui.log.warn('Could not configure WorkOS dashboard: Unauthorized'); + } else if (status === 403 || message.includes('permission')) { + ui.log.warn('Could not configure WorkOS dashboard: API key lacks permission'); + } else if (status === 422) { + ui.log.warn(`Could not configure WorkOS dashboard: Validation error`); + ui.log.info(` Error: ${message}`); + } else { + ui.log.warn(`Could not configure WorkOS dashboard: ${message}`); + } + + showManualInstructions(callbackUrl, baseUrl, homepageUrlValue); + + analytics.capture(INSTALLER_INTERACTION_EVENT_NAME, { + action: 'workos environment auto-config failed', + integration, + error: message, + status, + recoveryAttempts, + }); + + return null; + } } }