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...');