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
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ Input is passed via flags. Define options in the command's zod schema — incur
- The token endpoint echoes `scope` and `authorization_details` back with the tokens on login/refresh. These are persisted in the credential file (part of `AuthTokens`) and surfaced on `auth status` in both interactive and JSON modes, only when present.
- **Gotcha — two parallel `AuthResource` implementations.** `packages/cli/src/auth/auth-resource.ts` duplicates `packages/sdk/src/resources/auth.ts` (device auth flow, token parsing). The CLI uses its *own* via `ResourceFactory.createAuthResource()` (`packages/cli/src/utils/resource-factory.ts`) — the SDK class is not on the CLI's runtime path. Any change to token-response handling (new fields, parsing) must be applied to **both**, or the CLI silently drops it.

### auth upgrade

- `auth upgrade` — takes the **same flags** as `auth login` (reuses `loginOptions`; `--client-name`, `--scope`, `--source-actions`, `--authorization-detail`, `--interval`/`--timeout`/`--max-attempts`) and starts a new device-authorization requesting a **superset** of the current access. Implemented alongside `login` in `createAuthCli` (`packages/cli/src/commands/auth/index.tsx`); `auth login` is unchanged. The device-auth tail (initiate → yield code → poll) is shared with `login` via the `startDeviceAuthAndPoll` helper.
- Where `auth login` bails out with "already logged in" when a valid session exists, `auth upgrade` **never bails**: it refreshes the existing token, merges the requested `scope`/`authorization_details` with the currently granted access via `computeMergedAccess` (`packages/cli/src/auth/merge-access.ts`, returning `mergedScope` + `mergedAuthorizationDetails`), and initiates device auth for the union.
- If the existing token is invalid or absent, it writes a warning to **stderr** and includes a `warning` field in the JSON yield, then continues with only the requested access (never hard-fails). `--source-actions` are folded into `authorization_details` before merging (via `buildAuthorizationDetails`), so `source` merges by `type` like any other detail.
- **Deferred session replacement (key invariant).** Upgrade does **not** clear or revoke the current session up front — the existing grant stays valid throughout the pending approval, so a failed `initiateDeviceAuth` or an abandoned approval leaves it usable. The refreshed tokens are persisted; the pending device-auth record is flagged `replaces_existing_session` (field on `PendingDeviceAuth` in the SDK). `pollAuthStatus` completes a flagged pending **even while `isAuthenticated()` is true** (it doesn't report the old session as done), and on success swaps in the new tokens and **revokes the old grant**. The interactive path does the same via the `<Login>` `revokeRefreshTokenOnSuccess` prop. Abandon → the flagged pending expires (auto-cleared by `getPendingDeviceAuth`) and the old session remains.
- Scope-token comparison for the merge tolerates commas (the token endpoint echoes `scope` back comma-delimited) — but only inside `merge-access.ts`. `auth login`'s `--scope` parsing (`normalizeScopeInput` in `scopes.ts`) remains strictly space-separated, so `login` is genuinely unchanged.

### spend-request command

CLI command is `spend-request` (user-facing). Implemented in `packages/cli/src/commands/spend-request/`. SDK interfaces: `ISpendRequestResource`, `CreateSpendRequestParams`, `UpdateSpendRequestParams`. API endpoint: `/spend_requests`.
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ link-cli mpp pay https://climate.stripe.dev/api/contribute \
```bash
link-cli auth login --client-name "Claude Code" # identify the connecting agent
link-cli auth login --client-name "Claude Code" --interval 5 --timeout 300 # login + poll in one call
link-cli auth upgrade --scope "userinfo:read spend_requests:approve" # widen access to a superset
link-cli auth status # check auth status
link-cli auth logout # disconnect
```
Expand All @@ -207,6 +208,8 @@ When you provide `--client-name`, the Link app displays it when you approve the

With `--interval`, the login command yields the verification code immediately and then polls inline until authenticated or timed out — no separate `auth status` call needed. This is recommended for agents that cannot relay the code while a separate polling command blocks their I/O channel.

`auth upgrade` takes the same flags as `auth login` but is meant for widening access when you're already logged in. Unlike `auth login` — which stops with an "already logged in" message when a valid session exists — `auth upgrade` merges the flags you pass with your currently granted `scope` and `authorization_details` and starts a new approval for the **superset**, so you never accidentally drop access. If there's no valid session, it prints a warning and continues with just the access you requested. Your current session stays valid throughout the approval and is only replaced (and the old grant revoked) once you approve the new one — so abandoning the approval leaves your existing session untouched.

`auth status` reports the `scope` and `authorization_details` the current session was granted (echoed by the token endpoint at login/refresh and stored in the credential file), and includes an `update` field when a newer version is available:

```json
Expand Down
239 changes: 239 additions & 0 deletions packages/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1637,6 +1637,245 @@ describe('production mode', () => {
});
});

describe('auth upgrade', () => {
const DEVICE_CODE_RESPONSE = {
device_code: 'test_device_code',
user_code: 'apple-grape',
verification_uri: 'https://app.link.com/device/setup',
verification_uri_complete:
'https://app.link.com/device/setup?code=apple-grape',
expires_in: 300,
interval: 1,
};

const REFRESH_RESPONSE = {
access_token: 'refreshed_access_token',
refresh_token: 'refreshed_refresh_token',
expires_in: 3600,
token_type: 'Bearer',
};

it('does not bail when already authenticated — initiates a new device auth', async () => {
// beforeEach set a valid session (PROD_AUTH_TOKENS).
setResponseForUrl('/device/token', 200, REFRESH_RESPONSE);
setResponseForUrl('/device/code', 200, DEVICE_CODE_RESPONSE);

const result = await runProdCli(
'auth',
'upgrade',
'--client-name',
'My Agent',
'--json',
);

expect(result.exitCode).toBe(0);
const output = parseJson(result.stdout) as Record<string, unknown>[];
// Unlike `login`, upgrade does NOT return "already logged in".
expect(output[0].message).toBeUndefined();
expect(output[0].verification_url).toBeDefined();
expect(
requests.find((r) => r.url.includes('/device/code')),
).toBeDefined();
// Deferred lifecycle: the existing session is preserved (NOT cleared) and
// the pending is flagged so the poll completes the new approval and
// revokes the old grant only once the widened tokens land.
expect(storage.getAuth()).not.toBeNull();
expect(storage.getPendingDeviceAuth()?.replaces_existing_session).toBe(
true,
);
// Old grant is NOT revoked up front (only after the new approval lands).
expect(
requests.find((r) => r.url.includes('/device/revoke')),
).toBeUndefined();
});

it('merges the existing scope into a superset device/code request', async () => {
// Existing session grants the default scope; request only a subset.
setResponseForUrl('/device/token', 200, {
...REFRESH_RESPONSE,
scope: 'userinfo:read payment_methods.agentic',
});
setResponseForUrl('/device/code', 200, DEVICE_CODE_RESPONSE);

const result = await runProdCli(
'auth',
'upgrade',
'--client-name',
'My Agent',
'--scope',
'userinfo:read',
'--json',
);

expect(result.exitCode).toBe(0);
const deviceCodeRequest = requests.find((r) =>
r.url.includes('/device/code'),
);
expect(deviceCodeRequest).toBeDefined();
const params = new URLSearchParams(deviceCodeRequest?.body);
// The dropped scope is merged back in → superset requested.
expect(params.get('scope')).toBe('userinfo:read payment_methods.agentic');
});

it('merges existing source authorization_details that are not re-requested', async () => {
setResponseForUrl('/device/token', 200, {
...REFRESH_RESPONSE,
scope: 'userinfo:read payment_methods.agentic',
authorization_details: [
{
type: 'source',
resource_id: 'src_123',
actions: ['read_source_details'],
},
],
});
setResponseForUrl('/device/code', 200, DEVICE_CODE_RESPONSE);

const result = await runProdCli(
'auth',
'upgrade',
'--client-name',
'My Agent',
'--scope',
'userinfo:read payment_methods.agentic',
'--json',
);

expect(result.exitCode).toBe(0);
const params = new URLSearchParams(
requests.find((r) => r.url.includes('/device/code'))?.body,
);
expect(params.getAll('authorization_details[][type]')).toContain(
'source',
);
expect(params.getAll('authorization_details[][actions][]')).toContain(
'read_source_details',
);
});

it('unions a newly-requested source action with the already-granted ones', async () => {
// Existing session holds source:[read_balances]; request a DIFFERENT
// source action. The merged request must keep both.
setResponseForUrl('/device/token', 200, {
...REFRESH_RESPONSE,
scope: 'userinfo:read payment_methods.agentic',
authorization_details: [
{
type: 'source',
resource_id: 'src_123',
actions: ['read_balances'],
},
],
});
setResponseForUrl('/device/code', 200, DEVICE_CODE_RESPONSE);

const result = await runProdCli(
'auth',
'upgrade',
'--client-name',
'My Agent',
'--source-actions',
'read_external_transactions',
'--json',
);

expect(result.exitCode).toBe(0);
const params = new URLSearchParams(
requests.find((r) => r.url.includes('/device/code'))?.body,
);
expect(params.getAll('authorization_details[][type]')).toEqual([
'source',
]);
expect(params.getAll('authorization_details[][actions][]')).toEqual([
'read_external_transactions',
'read_balances',
]);
});

it('warns and continues when there is no active session', async () => {
storage.clearAuth();
setResponseForUrl('/device/code', 200, DEVICE_CODE_RESPONSE);

const result = await runProdCli(
'auth',
'upgrade',
'--client-name',
'My Agent',
'--scope',
'userinfo:read',
'--json',
);

expect(result.exitCode).toBe(0);
expect(result.stderr).toMatch(/no active session/i);
// No refresh attempted; still initiates device auth with requested access.
expect(
requests.find((r) => r.url.includes('/device/token')),
).toBeUndefined();
const params = new URLSearchParams(
requests.find((r) => r.url.includes('/device/code'))?.body,
);
expect(params.get('scope')).toBe('userinfo:read');
});

it('warns and continues when the existing token is no longer valid', async () => {
// Valid session present, but refresh fails.
setResponseForUrl('/device/token', 401, { error: 'invalid_grant' });
setResponseForUrl('/device/code', 200, DEVICE_CODE_RESPONSE);

const result = await runProdCli(
'auth',
'upgrade',
'--client-name',
'My Agent',
'--scope',
'userinfo:read',
'--json',
);

expect(result.exitCode).toBe(0);
expect(result.stderr).toMatch(/could not refresh/i);
// Falls back to requested-only access, but still initiates device auth.
const params = new URLSearchParams(
requests.find((r) => r.url.includes('/device/code'))?.body,
);
expect(params.get('scope')).toBe('userinfo:read');
});

it('with --interval, completes the new approval and revokes the old grant', async () => {
// Valid session present; both the refresh and the device poll resolve via
// /device/token (the stub returns the same body for each).
setResponseForUrl('/device/token', 200, REFRESH_RESPONSE);
setResponseForUrl('/device/code', 200, DEVICE_CODE_RESPONSE);
setResponseForUrl('/device/revoke', 200, 'ok');

const result = await runProdCli(
'auth',
'upgrade',
'--client-name',
'My Agent',
'--scope',
'userinfo:read',
'--interval',
'1',
'--timeout',
'5',
'--json',
);

expect(result.exitCode).toBe(0);
const output = parseJson(result.stdout) as Record<string, unknown>[];
// First yield is the verification code; a later yield reports authenticated.
expect(output[0].verification_url).toBeDefined();
expect(output[output.length - 1].authenticated).toBe(true);
// The poll completed the NEW approval (did not short-circuit on the still
// valid old session) and revoked the replaced grant on success.
expect(
requests.find((r) => r.url.includes('/device/revoke')),
).toBeDefined();
});
});

describe('auth logout', () => {
it('sends POST to /device/revoke with refresh token then clears auth', async () => {
setResponseForUrl('/device/revoke', 200, 'ok');
Expand Down
Loading
Loading