From 782fefc66548e0490b21b89e706d1d3ee9b1fa90 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Fri, 25 Sep 2026 05:37:10 -0500 Subject: [PATCH] fix(install): verify claim status before setting a default homepage A locally unclaimed profile can be stale after the environment is claimed elsewhere. Reuse the claim-nonce API to confirm live status before implicit homepage writes, and skip writes on failed or unavailable confirmation. Apply the check in the shared helper for both installer paths, matching the expected client ID when available. Explicit homepage overrides remain unchanged. This is a best-effort preflight, not an atomic claim/write operation. --- src/lib/authkit-application-setup.spec.ts | 79 +++++++++++++++++++++-- src/lib/authkit-application-setup.ts | 6 +- src/lib/workos-management.spec.ts | 28 +++++++- src/lib/workos-management.ts | 41 +++++++----- 4 files changed, 127 insertions(+), 27 deletions(-) diff --git a/src/lib/authkit-application-setup.spec.ts b/src/lib/authkit-application-setup.spec.ts index ea22acb9..2d0f4114 100644 --- a/src/lib/authkit-application-setup.spec.ts +++ b/src/lib/authkit-application-setup.spec.ts @@ -225,9 +225,10 @@ describe('native application URL setup', () => { clientId: setup.clientId, claimToken: 'claim', }); - const request = vi.fn(async (_url: string, init: RequestInit) => - init.method === 'GET' ? new Response(null, { status: 404 }) : Response.json({}), - ); + const request = vi.fn(async (url: string, init: RequestInit) => { + if (url.endsWith('/claim-nonces')) return Response.json({ nonce: 'claim_nonce' }); + return init.method === 'GET' ? new Response(null, { status: 404 }) : Response.json({}); + }); vi.stubGlobal('fetch', request); const result = await configureAuthkitApplication( { @@ -440,11 +441,24 @@ describe('native application URL setup', () => { clientId: setup.clientId, claimToken: 'claim', }); - const request = vi.fn(async (_url: string, init: RequestInit) => - init.method === 'GET' ? new Response(null, { status: 404 }) : Response.json({}), - ); + const request = vi.fn(async (url: string, init: RequestInit) => { + if (url.endsWith('/claim-nonces')) return Response.json({ nonce: 'claim_nonce' }); + return init.method === 'GET' ? new Response(null, { status: 404 }) : Response.json({}); + }); vi.stubGlobal('fetch', request); const result = await configureAuthkitApplication(setup, setup.clientId, 'sk_test_unclaimed'); + expect(request).toHaveBeenCalledWith( + 'https://api.workos.com/x/one-shot-environments/claim-nonces', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ client_id: setup.clientId, claim_token: 'claim' }), + }), + ); + expect(request.mock.calls.filter(([, init]) => init.method !== 'GET').map(([url]) => url)).toEqual([ + 'https://api.workos.com/user_management/redirect_uris', + 'https://api.workos.com/x/one-shot-environments/claim-nonces', + 'https://api.workos.com/user_management/app_homepage_url', + ]); expect(request).toHaveBeenCalledWith( 'https://api.workos.com/user_management/app_homepage_url', expect.objectContaining({ @@ -458,7 +472,50 @@ describe('native application URL setup', () => { expect(dashboardGraphqlRequest).not.toHaveBeenCalled(); }); - it.each(['unknown', 'claimed', 'different-unclaimed', 'unavailable'])( + it.each([ + ['claimed externally', () => Response.json({ already_claimed: true })], + ['claim conflict', () => new Response(null, { status: 409 })], + ['invalid claim token', () => new Response(null, { status: 401 })], + ['missing environment', () => new Response(null, { status: 404 })], + ['rate limited', () => new Response(null, { status: 429 })], + ['server error', () => new Response(null, { status: 500 })], + ['invalid response', () => Response.json({})], + ['network failure', () => Promise.reject(new Error('private details'))], + ['timeout', () => Promise.reject(new DOMException('private details', 'AbortError'))], + ] as const)('preserves the API-only homepage when live claim status reports %s', async (_, claim) => { + vi.mocked(refreshIfExpired).mockResolvedValue(null); + vi.mocked(getActiveEnvironment).mockReturnValue({ + name: 'Unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_unclaimed', + clientId: setup.clientId, + claimToken: 'claim', + }); + const request = vi.fn(async (url: string) => (url.endsWith('/claim-nonces') ? claim() : Response.json({}))); + vi.stubGlobal('fetch', request); + + const result = await configureAuthkitApplication(setup, setup.clientId, 'sk_test_unclaimed'); + + expect(request.mock.calls.some(([url]) => url.endsWith('/app_homepage_url'))).toBe(false); + expect(result.callbackRegistered).toBe(true); + expect(result.verified).toBe(false); + expect(result.reason).toContain('Homepage URL was left unchanged'); + expect(result.reason).not.toContain('private details'); + + request.mockClear(); + await configureAuthkitApplication( + { ...setup, homepageUrl: 'https://requested.example/' }, + setup.clientId, + 'sk_test_unclaimed', + ); + expect(request.mock.calls.some(([url]) => url.endsWith('/claim-nonces'))).toBe(false); + expect(request).toHaveBeenCalledWith( + 'https://api.workos.com/user_management/app_homepage_url', + expect.objectContaining({ method: 'PUT', body: JSON.stringify({ url: 'https://requested.example/' }) }), + ); + }); + + it.each(['unknown', 'claimed', 'different-unclaimed', 'different-client', 'unavailable'])( 'skips the unknown API-only homepage for a %s key unless explicitly overridden', async (kind) => { vi.mocked(refreshIfExpired).mockResolvedValue(null); @@ -476,6 +533,14 @@ describe('native application URL setup', () => { clientId: setup.clientId, claimToken: 'claim', }); + if (kind === 'different-client') + vi.mocked(getActiveEnvironment).mockReturnValue({ + name: 'Unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_unclaimed', + clientId: 'client_other', + claimToken: 'claim', + }); if (kind === 'unavailable') vi.mocked(getActiveEnvironment).mockImplementation(() => { throw new Error('keyring unavailable'); diff --git a/src/lib/authkit-application-setup.ts b/src/lib/authkit-application-setup.ts index a418165a..9d8ec35f 100644 --- a/src/lib/authkit-application-setup.ts +++ b/src/lib/authkit-application-setup.ts @@ -158,10 +158,10 @@ export async function configureAuthkitApplication( } } // REST cannot read the current homepage. Only an explicit override or the - // stored active unclaimed key authorizes replacing this single-valued setting. - if (setup.homepageUrl === undefined && !isUnclaimedEnvironmentKey(apiKey)) { + // matching stored environment confirmed still unclaimed authorizes a default. + if (setup.homepageUrl === undefined && !(await isUnclaimedEnvironmentKey(apiKey, setup.clientId))) { return pending( - 'Callback registered using the API key. Homepage URL was left unchanged because its current value cannot be read and this key does not match the stored active unclaimed environment. Supply --homepage-url to override it. Sign-out URI and Initiate login URI still require dashboard setup and verification.', + 'Callback registered using the API key. Homepage URL was left unchanged because its current value cannot be read and the environment could not be confirmed as unclaimed. Supply --homepage-url to override it. Sign-out URI and Initiate login URI still require dashboard setup and verification.', ); } try { diff --git a/src/lib/workos-management.spec.ts b/src/lib/workos-management.spec.ts index 38a9143e..f413d3d7 100644 --- a/src/lib/workos-management.spec.ts +++ b/src/lib/workos-management.spec.ts @@ -58,6 +58,7 @@ function stubFetch(homepage: (method: string) => Response | Promise): const stub = vi.fn(async (url: string, init: { method: string }) => { calls.push({ url, method: init.method }); if (url === HOMEPAGE_ENDPOINT) return homepage(init.method); + if (url.endsWith('/claim-nonces')) return Response.json({ nonce: 'claim_nonce' }); return jsonResponse(201, {}); }); vi.stubGlobal('fetch', stub); @@ -192,6 +193,7 @@ describe('workos-management', () => { vi.stubGlobal( 'fetch', vi.fn(async (url: string, init: { method: string; body?: string }) => { + if (url.endsWith('/claim-nonces')) return Response.json({ nonce: 'claim_nonce' }); if (url === HOMEPAGE_ENDPOINT && init.method === 'GET') bodies.push(init.body); return jsonResponse(200, { url: BASE_URL }); }), @@ -207,6 +209,7 @@ describe('workos-management', () => { vi.stubGlobal( 'fetch', vi.fn(async (url: string, init: { method: string; headers: Record }) => { + if (url.endsWith('/claim-nonces')) return Response.json({ nonce: 'claim_nonce' }); if (url === HOMEPAGE_ENDPOINT) { seen.push({ method: init.method, contentType: init.headers['Content-Type'] }); } @@ -366,13 +369,35 @@ describe('workos-management', () => { expect(homepageCalls(calls, 'PUT')).toHaveLength(0); }); - it('sets it on an unclaimed environment, whose dashboard nobody has used', async () => { + it.each([ + ['claimed elsewhere', () => Response.json({ already_claimed: true })], + ['claim conflict', () => new Response(null, { status: 409 })], + ['unavailable claim status', () => new Response(null, { status: 500 })], + ] as const)('preserves the homepage for a locally unclaimed profile with %s', async (_, claim) => { + getActiveEnvironment.mockReturnValue(unclaimedEnv); + const request = vi.fn(async (url: string) => (url.endsWith('/claim-nonces') ? claim() : Response.json({}))); + vi.stubGlobal('fetch', request); + + const result = await autoConfigureWorkOSEnvironment(API_KEY, INTEGRATION, PORT); + + expect(result).not.toBeNull(); + expect(result?.redirectUri.success).toBe(true); + expect(result?.corsOrigin.success).toBe(true); + expect(result?.homepageUrl).toBeUndefined(); + expect(request.mock.calls.some(([url]) => url === HOMEPAGE_ENDPOINT)).toBe(false); + expect(rowFor('Homepage URL')).toMatchObject({ status: 'not changed; check the dashboard' }); + }); + + it('sets it after confirming the stored environment is still unclaimed', async () => { getActiveEnvironment.mockReturnValue(unclaimedEnv); const { calls } = stubFetch((method) => (method === 'GET' ? jsonResponse(404, {}) : jsonResponse(200, {}))); await autoConfigureWorkOSEnvironment(API_KEY, INTEGRATION, PORT); expect(homepageCalls(calls, 'PUT')).toHaveLength(1); + expect(calls.findIndex(({ url }) => url.endsWith('/claim-nonces'))).toBeLessThan( + calls.findIndex(({ url, method }) => url === HOMEPAGE_ENDPOINT && method === 'PUT'), + ); }); it('sets the homepage the user asked for (--homepage-url) on any environment', async () => { @@ -382,6 +407,7 @@ describe('workos-management', () => { await autoConfigureWorkOSEnvironment(API_KEY, INTEGRATION, PORT, { homepageUrl: 'https://app.example.com' }); expect(homepageCalls(calls, 'PUT')).toHaveLength(1); + expect(calls.some(({ url }) => url.endsWith('/claim-nonces'))).toBe(false); }); }); diff --git a/src/lib/workos-management.ts b/src/lib/workos-management.ts index 924d07c3..acb985e0 100644 --- a/src/lib/workos-management.ts +++ b/src/lib/workos-management.ts @@ -7,6 +7,7 @@ import { formatWorkOSCommand } from '../utils/command-invocation.js'; import ui from '../utils/ui.js'; import { getActiveEnvironment, isUnclaimedEnvironment } from './config-store.js'; import { getCallbackPath } from './port-detection.js'; +import { createClaimNonce } from './unclaimed-env-api.js'; const WORKOS_API_BASE = 'https://api.workos.com'; @@ -147,6 +148,25 @@ export async function setHomepageUrl( return { success: true, alreadyExists: false }; } +/** Best-effort live claim check; this is not atomic with the later homepage write. */ +export async function isUnclaimedEnvironmentKey(apiKey: string, clientId?: string): Promise { + try { + const environment = getActiveEnvironment(); + if ( + !environment || + !isUnclaimedEnvironment(environment) || + environment.apiKey !== apiKey || + (clientId !== undefined && environment.clientId !== clientId) + ) + return false; + const claim = await createClaimNonce(environment.clientId, environment.claimToken); + return !claim.alreadyClaimed; + } catch { + // Unavailable keyring or claim status: unknown, so leave the homepage alone. + return false; + } +} + /** * Where the credentials being used came from, so the rows below say *where* the * writes landed and not just what was written. @@ -161,17 +181,6 @@ export async function setHomepageUrl( * store entirely, and naming an untouched environment is exactly the confusion * this row exists to prevent. */ -/** Whether `apiKey` is the key of the stored, still-unclaimed environment. */ -export function isUnclaimedEnvironmentKey(apiKey: string): boolean { - try { - const environment = getActiveEnvironment(); - return !!environment && isUnclaimedEnvironment(environment) && environment.apiKey === apiKey; - } catch { - // Keyring unavailable: unknown, so treat it as claimed. - return false; - } -} - function describeCredentialProvenance(apiKey: string): string { let activeEnv: EnvironmentConfig | null = null; try { @@ -245,11 +254,11 @@ export async function autoConfigureWorkOSEnvironment( // The homepage is one value per environment, and the REST API can set it // but not read it (the GET answers 404), so this step can't tell whether - // someone already chose one. Write it only when nothing can be overwritten: - // the user asked for it (--homepage-url), or the environment is unclaimed, - // so nobody has had its dashboard. With a login, the later dashboard step - // reads the current value and fills an empty one. - const writeHomepage = Boolean(options.homepageUrl) || isUnclaimedEnvironmentKey(apiKey); + // someone already chose one. Write only an explicit homepage or a default + // for an environment the server still reports as unclaimed. Local claim + // status can be stale. With a login, the later dashboard step reads the + // current value and fills an empty one. + const writeHomepage = Boolean(options.homepageUrl) || (await isUnclaimedEnvironmentKey(apiKey)); ui.log.step('Configuring WorkOS dashboard settings...');