Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 72 additions & 7 deletions src/lib/authkit-application-setup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down Expand Up @@ -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({
Expand All @@ -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);
Expand All @@ -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');
Expand Down
6 changes: 3 additions & 3 deletions src/lib/authkit-application-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
28 changes: 27 additions & 1 deletion src/lib/workos-management.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ function stubFetch(homepage: (method: string) => Response | Promise<Response>):
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);
Expand Down Expand Up @@ -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 });
}),
Expand All @@ -207,6 +209,7 @@ describe('workos-management', () => {
vi.stubGlobal(
'fetch',
vi.fn(async (url: string, init: { method: string; headers: Record<string, string> }) => {
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'] });
}
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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);
});
});

Expand Down
41 changes: 25 additions & 16 deletions src/lib/workos-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<boolean> {
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Claim check uses another host If the active profile has a custom endpoint or WORKOS_API_URL is set, this nonce request goes to that host, while the homepage PUT still goes to https://api.workos.com. A failed check can leave the homepage unset even when the write endpoint is available, and a successful check does not confirm claim status at the host receiving the write. Use the same API host for both requests.

Knowledge Base Used: Authentication and configuration lifecycle

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/workos-management.ts
Line: 162

Comment:
**Claim check uses another host** If the active profile has a custom endpoint or `WORKOS_API_URL` is set, this nonce request goes to that host, while the homepage PUT still goes to `https://api.workos.com`. A failed check can leave the homepage unset even when the write endpoint is available, and a successful check does not confirm claim status at the host receiving the write. Use the same API host for both requests.

**Knowledge Base Used:** [Authentication and configuration lifecycle](https://app.greptile.com/workos/-/custom-context/knowledge-base/workos/cli/-/docs/authentication-and-configuration.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Claim check delays other settings This path waits for the nonce request before starting redirect and CORS registration or showing setup progress. If the request stalls, its 30-second timeout delays both settings, although claim status is needed only for the homepage decision. Start the additive settings independently of the check.

Knowledge Base Used: Application installation workflows

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/workos-management.ts
Line: 261

Comment:
**Claim check delays other settings** This path waits for the nonce request before starting redirect and CORS registration or showing setup progress. If the request stalls, its 30-second timeout delays both settings, although claim status is needed only for the homepage decision. Start the additive settings independently of the check.

**Knowledge Base Used:** [Application installation workflows](https://app.greptile.com/workos/-/custom-context/knowledge-base/workos/cli/-/docs/application-installation.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.


ui.log.step('Configuring WorkOS dashboard settings...');

Expand Down
Loading