diff --git a/CLAUDE.md b/CLAUDE.md index b9eaaff..071e411 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `` `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`. diff --git a/README.md b/README.md index e1ee7f4..d527d91 100644 --- a/README.md +++ b/README.md @@ -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 ``` @@ -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 diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index 823937e..6f5b6af 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -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[]; + // 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[]; + // 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'); diff --git a/packages/cli/src/auth/__tests__/merge-access.test.ts b/packages/cli/src/auth/__tests__/merge-access.test.ts new file mode 100644 index 0000000..1a52759 --- /dev/null +++ b/packages/cli/src/auth/__tests__/merge-access.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import { computeMergedAccess } from '../merge-access'; +import type { JsonValue } from '../types'; + +describe('computeMergedAccess', () => { + it('returns the requested access unchanged when it already covers the existing access', () => { + const merged = computeMergedAccess({ + requestedScope: 'userinfo:read payment_methods.agentic', + requestedAuthorizationDetails: [], + existingScope: 'userinfo:read payment_methods.agentic', + existingAuthorizationDetails: [], + }); + + expect(merged.mergedScope).toBe('userinfo:read payment_methods.agentic'); + expect(merged.mergedAuthorizationDetails).toEqual([]); + }); + + it('merges scopes present in the existing session but absent from the request', () => { + const merged = computeMergedAccess({ + requestedScope: 'userinfo:read', + requestedAuthorizationDetails: [], + existingScope: + 'userinfo:read payment_methods.agentic spend_requests:approve', + existingAuthorizationDetails: [], + }); + + expect(merged.mergedScope).toBe( + 'userinfo:read payment_methods.agentic spend_requests:approve', + ); + }); + + it('treats comma-delimited existing scope the same as space-delimited', () => { + const merged = computeMergedAccess({ + requestedScope: 'userinfo:read payment_methods.agentic', + requestedAuthorizationDetails: [], + existingScope: 'userinfo:read,payment_methods.agentic', + existingAuthorizationDetails: [], + }); + + // Nothing missing — comma form parses to the same tokens as the request. + expect(merged.mergedScope).toBe('userinfo:read payment_methods.agentic'); + }); + + it('falls back to the default scope for both sides when scope is unset', () => { + const merged = computeMergedAccess({ + requestedAuthorizationDetails: [], + existingAuthorizationDetails: [], + }); + + expect(merged.mergedScope).toBe('userinfo:read payment_methods.agentic'); + }); + + it('unions source actions across requested and existing (keeps already-granted actions)', () => { + const merged = computeMergedAccess({ + requestedScope: 'userinfo:read payment_methods.agentic', + requestedAuthorizationDetails: [ + { type: 'source', actions: ['read_external_transactions'] }, + ], + existingScope: 'userinfo:read payment_methods.agentic', + existingAuthorizationDetails: [ + { + type: 'source', + resource_id: 'src_123', + actions: ['read_balances'], + }, + ], + }); + + // The requested action does NOT drop the already-granted action — union. + expect(merged.mergedAuthorizationDetails).toEqual([ + { + type: 'source', + actions: ['read_external_transactions', 'read_balances'], + }, + ]); + }); + + it('rebuilds existing source access (unioned by type) when it is not re-requested', () => { + const merged = computeMergedAccess({ + requestedScope: 'userinfo:read payment_methods.agentic', + requestedAuthorizationDetails: [], + existingScope: 'userinfo:read payment_methods.agentic', + existingAuthorizationDetails: [ + { + type: 'source', + resource_id: 'src_123', + actions: ['read_source_details'], + }, + { + type: 'source', + resource_id: 'src_456', + actions: ['read_link_transactions'], + }, + ], + }); + + expect(merged.mergedAuthorizationDetails).toEqual([ + { + type: 'source', + actions: ['read_source_details', 'read_link_transactions'], + }, + ]); + }); + + it('preserves opaque (non-{type,actions}) existing details verbatim', () => { + const opaque: JsonValue = { type: 'account', filters: ['current'] }; + + const merged = computeMergedAccess({ + requestedScope: 'userinfo:read payment_methods.agentic', + requestedAuthorizationDetails: [], + existingScope: 'userinfo:read payment_methods.agentic', + existingAuthorizationDetails: [opaque], + }); + + expect(merged.mergedAuthorizationDetails).toEqual([opaque]); + }); + + it('unions action-based details by type and keeps opaque requested/passthrough entries', () => { + const passthrough: JsonValue = { type: 'account', filters: ['current'] }; + + const merged = computeMergedAccess({ + requestedScope: 'userinfo:read payment_methods.agentic', + requestedAuthorizationDetails: [ + { type: 'account', actions: ['transfer'] }, + passthrough, + true, + ], + existingScope: 'userinfo:read payment_methods.agentic', + existingAuthorizationDetails: [ + { type: 'account', resource_id: 'acct_123', actions: ['read'] }, + ], + }); + + // The action-based 'account' detail unions requested + existing actions; + // the opaque passthrough entries are preserved. First-seen order. + expect(merged.mergedAuthorizationDetails).toEqual([ + { type: 'account', actions: ['transfer', 'read'] }, + passthrough, + true, + ]); + }); +}); diff --git a/packages/cli/src/auth/merge-access.ts b/packages/cli/src/auth/merge-access.ts new file mode 100644 index 0000000..b30d0ef --- /dev/null +++ b/packages/cli/src/auth/merge-access.ts @@ -0,0 +1,173 @@ +// Computes the superset of requested and currently-granted access for +// `auth upgrade`: the union of scopes and of authorization_details. Action-based +// details (`{ type, actions }`, e.g. `source`) are unioned per type so no +// already-granted action is dropped; opaque details are preserved verbatim. +import { DEFAULT_SCOPE } from './scopes'; +import type { JsonValue } from './types'; + +const DEFAULT_SCOPE_TOKENS = DEFAULT_SCOPE.split(' '); + +export interface MergedAccess { + mergedScope: string; + mergedAuthorizationDetails: JsonValue[]; +} + +interface ComputeMergedAccessOptions { + existingAuthorizationDetails?: readonly JsonValue[]; + existingScope?: string; + requestedAuthorizationDetails?: readonly JsonValue[]; + requestedScope?: string; +} + +// Dedupe while preserving first-seen order. +function dedupePreserveOrder(values: readonly T[]): T[] { + const seen = new Set(); + const deduped: T[] = []; + + for (const value of values) { + if (seen.has(value)) { + continue; + } + + seen.add(value); + deduped.push(value); + } + + return deduped; +} + +// Concatenate then dedupe: `current` entries win their position over dupes. +function unionPreserveOrder( + current: readonly T[], + additional: readonly T[], +): T[] { + return dedupePreserveOrder([...current, ...additional]); +} + +// Narrow a JsonValue to a plain object (not null, not an array). +function isRecord(value: JsonValue): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +// Safely read the string `type` field off an authorization detail, if present. +function getDetailType(detail: JsonValue): string | undefined { + if (!isRecord(detail) || typeof detail.type !== 'string') { + return undefined; + } + + return detail.type; +} + +// Return the value as a string[] only if it's an array of all strings, else null. +function getStringArray(value: JsonValue | undefined): string[] | null { + if ( + !Array.isArray(value) || + value.some((entry) => typeof entry !== 'string') + ) { + return null; + } + + return value as string[]; +} + +// Tokenize a scope string on whitespace OR commas. The token endpoint echoes +// scope back comma-delimited, so the currently-granted scope must tokenize the +// same way space-separated `--scope` input does. (User `--scope` input stays +// strictly space-separated via `normalizeScopeInput`; comma tolerance is +// isolated here.) Falls back to the default scope when empty so an unspecified +// request compares against the baseline the server would grant. +function scopeTokens( + scope: string | undefined, + fallbackToDefault: boolean, +): string[] { + const tokens = (scope ?? '') + .trim() + .split(/[\s,]+/) + .filter(Boolean); + + if (tokens.length > 0) { + return tokens; + } + + return fallbackToDefault ? [...DEFAULT_SCOPE_TOKENS] : []; +} + +// Read the deduped `actions` array off a granted detail, or null if the detail +// doesn't fit the expected `{ type, actions }` shape (opaque detail). +function grantedActions(detail: JsonValue): string[] | null { + if (!isRecord(detail)) { + return null; + } + + const actions = getStringArray(detail.actions); + return actions ? dedupePreserveOrder(actions) : null; +} + +// Merge requested access with the currently-granted access to produce the +// superset to request on `auth upgrade`. Scopes are unioned; action-based +// authorization details (`{ type, actions }`) are unioned per type so existing +// actions survive even when the same type is re-requested with different +// actions; opaque details are preserved verbatim. +export function computeMergedAccess({ + requestedScope, + requestedAuthorizationDetails, + existingScope, + existingAuthorizationDetails, +}: ComputeMergedAccessOptions): MergedAccess { + const requestedTokens = scopeTokens(requestedScope, true); + const existingTokens = scopeTokens(existingScope, true); + const missingScopes = existingTokens.filter( + (token) => !requestedTokens.includes(token), + ); + const mergedScope = unionPreserveOrder(requestedTokens, missingScopes).join( + ' ', + ); + + // Union authorization details across requested + existing. Action-based + // `{ type, actions }` details (e.g. `source`) are merged per type so the + // request keeps ALL granted actions of a type — not just the ones + // re-specified this run. (Requesting `source: [read_external_transactions]` + // when the session already holds `source: [read_balances]` must request + // both.) Opaque details (no `actions` array — e.g. `{ type, filters }`, or + // non-objects) are preserved verbatim and de-duplicated. First-seen order is + // preserved, with requested details ahead of existing-only ones. + const actionsByType = new Map(); + const seenOpaque = new Set(); + const layout: Array< + { kind: 'actions'; type: string } | { kind: 'opaque'; detail: JsonValue } + > = []; + + const absorb = (details: readonly JsonValue[]) => { + for (const detail of details) { + const type = getDetailType(detail); + const actions = grantedActions(detail); + if (type && actions) { + if (!actionsByType.has(type)) { + layout.push({ kind: 'actions', type }); + } + actionsByType.set( + type, + unionPreserveOrder(actionsByType.get(type) ?? [], actions), + ); + } else { + // Opaque/unparseable detail — preserve verbatim, de-duplicated. + const key = JSON.stringify(detail); + if (!seenOpaque.has(key)) { + seenOpaque.add(key); + layout.push({ kind: 'opaque', detail }); + } + } + } + }; + + absorb(requestedAuthorizationDetails ?? []); + absorb(existingAuthorizationDetails ?? []); + + const mergedAuthorizationDetails = layout.map((entry) => + entry.kind === 'actions' + ? { type: entry.type, actions: actionsByType.get(entry.type) ?? [] } + : entry.detail, + ); + + return { mergedScope, mergedAuthorizationDetails }; +} diff --git a/packages/cli/src/commands/auth/index.tsx b/packages/cli/src/commands/auth/index.tsx index dd771a5..8b3c3f5 100644 --- a/packages/cli/src/commands/auth/index.tsx +++ b/packages/cli/src/commands/auth/index.tsx @@ -1,8 +1,16 @@ -import { type AuthStorage, storage as defaultStorage } from '@stripe/link-sdk'; +import { + type AuthStorage, + type SourceAction, + storage as defaultStorage, +} from '@stripe/link-sdk'; import { Cli } from 'incur'; import { Text } from 'ink'; import React from 'react'; -import { parseAuthorizationDetails } from '../../auth/authorization-details'; +import { + buildAuthorizationDetails, + parseAuthorizationDetails, +} from '../../auth/authorization-details'; +import { computeMergedAccess } from '../../auth/merge-access'; import { normalizeScopeInput } from '../../auth/scopes'; import type { IAuthResource, JsonValue } from '../../auth/types'; import { pollUntil } from '../../utils/poll-until'; @@ -34,6 +42,46 @@ async function* pollAuthStatus( for await (const result of pollUntil({ fn: async () => { const pending = storage.getPendingDeviceAuth(); + + // `auth upgrade` in progress: this device authorization replaces a + // still-valid session, so complete it even though we're authenticated, + // and do NOT report the old session as done until the new tokens land. + // On success, swap in the new tokens and revoke the old grant. + if (pending?.replaces_existing_session) { + const previousRefreshToken = storage.getAuth()?.refresh_token; + const tokens = await authResource.pollDeviceAuth(pending.device_code); + if (tokens) { + storage.setAuth(tokens); + storage.clearPendingDeviceAuth(); + if (previousRefreshToken) { + try { + await authResource.revokeToken(previousRefreshToken); + } catch { + // best-effort: the widened session is already stored + } + } + return { + authenticated: true as const, + access_token: `${tokens.access_token.substring(0, 20)}...`, + token_type: tokens.token_type, + credentials_path: storage.getPath(), + ...(tokens.scope && { scope: tokens.scope }), + ...(tokens.authorization_details && { + authorization_details: tokens.authorization_details, + }), + ...(update && { update }), + }; + } + return { + authenticated: false as const, + credentials_path: storage.getPath(), + ...(update && { update }), + pending: true, + verification_url: pending.verification_url, + phrase: pending.phrase, + }; + } + if (pending && !storage.isAuthenticated()) { const tokens = await authResource.pollDeviceAuth(pending.device_code); if (tokens) { @@ -96,6 +144,72 @@ async function maybeRevokeAndClearAuth( storage.clearPendingDeviceAuth(); } +interface DeviceAuthParams { + clientName?: string; + scope?: string; + sourceActions?: SourceAction[]; + authorizationDetails?: JsonValue[]; + // Marks the pending as replacing a still-valid session (see pollAuthStatus). + replacesExistingSession?: boolean; +} + +// Shared device-authorization tail for `login` and `upgrade`: initiate the +// device flow, persist the pending record, yield the verification code, and +// (when `--interval` polling is requested) poll inline until terminal. An +// optional `warning` is attached to the first yield for degraded-mode callers. +async function* startDeviceAuthAndPoll( + authResource: IAuthResource, + storage: AuthStorage, + params: DeviceAuthParams, + opts: PollAuthOptions, + warning?: string, +) { + const authRequest = await authResource.initiateDeviceAuth({ + clientName: params.clientName, + scope: params.scope, + sourceActions: params.sourceActions, + authorizationDetails: params.authorizationDetails, + }); + storage.setPendingDeviceAuth({ + device_code: authRequest.device_code, + interval: authRequest.interval, + expires_at: Date.now() + authRequest.expires_in * 1000, + verification_url: authRequest.verification_url_complete, + phrase: authRequest.user_code, + ...(params.replacesExistingSession + ? { replaces_existing_session: true } + : {}), + }); + + const warningField = warning ? { warning } : {}; + + if (opts.interval <= 0) { + yield sanitizeDeep({ + ...warningField, + verification_url: authRequest.verification_url_complete, + phrase: authRequest.user_code, + instruction: + 'Present the verification_url to the user and ask them to approve in the Link app. Then call `auth status --interval 5 --max-attempts 60` to poll until authenticated. Do not wait for the user to reply — start polling immediately.', + _next: { + command: 'auth status --interval 5 --max-attempts 60', + poll_interval_seconds: authRequest.interval, + until: 'authenticated is true', + }, + }); + return; + } + + yield sanitizeDeep({ + ...warningField, + verification_url: authRequest.verification_url_complete, + phrase: authRequest.user_code, + instruction: + 'Present the verification_url to the user and ask them to approve in the Link app. Polling has started automatically — no further action needed.', + }); + + yield* pollAuthStatus(authResource, storage, opts); +} + export function createAuthCli( authResource: IAuthResource, getUpdateInfo?: UpdateInfoProvider, @@ -181,49 +295,140 @@ export function createAuthCli( ); } - const authRequest = await authResource.initiateDeviceAuth({ - clientName, - scope, - sourceActions: c.options.sourceActions, - authorizationDetails, - }); - storage.setPendingDeviceAuth({ - device_code: authRequest.device_code, - interval: authRequest.interval, - expires_at: Date.now() + authRequest.expires_in * 1000, - verification_url: authRequest.verification_url_complete, - phrase: authRequest.user_code, - }); + yield* startDeviceAuthAndPoll( + authResource, + storage, + { + clientName, + scope, + sourceActions: c.options.sourceActions, + authorizationDetails, + }, + { + interval: c.options.interval, + maxAttempts: c.options.maxAttempts, + timeout: c.options.timeout, + }, + ); + }, + }); - const interval = c.options.interval; - - if (interval <= 0) { - yield sanitizeDeep({ - verification_url: authRequest.verification_url_complete, - phrase: authRequest.user_code, - instruction: - 'Present the verification_url to the user and ask them to approve in the Link app. Then call `auth status --interval 5 --max-attempts 60` to poll until authenticated. Do not wait for the user to reply — start polling immediately.', - _next: { - command: 'auth status --interval 5 --max-attempts 60', - poll_interval_seconds: authRequest.interval, - until: 'authenticated is true', - }, + cli.command('upgrade', { + description: + 'Re-authenticate with Link, merging the requested access with your current access so the new session is a superset', + options: loginOptions, + outputPolicy: 'agent-only' as const, + async *run(c) { + const clientName = c.options.clientName?.trim(); + const requestedScope = normalizeScopeInput(c.options.scope); + if (!clientName || clientName.length === 0) { + return c.error({ + code: 'INVALID_INPUT', + message: 'client-name must be a non-empty string', + }); + } + if (c.options.scope !== undefined && !requestedScope) { + return c.error({ + code: 'INVALID_INPUT', + message: 'scope must be a non-empty string when provided', }); - return; } - yield sanitizeDeep({ - verification_url: authRequest.verification_url_complete, - phrase: authRequest.user_code, - instruction: - 'Present the verification_url to the user and ask them to approve in the Link app. Polling has started automatically — no further action needed.', - }); + let requestedAuthorizationDetails: JsonValue[]; + try { + // Fold --source-actions into authorization details up front so `source` + // merges like any other authorization-detail type below. + requestedAuthorizationDetails = buildAuthorizationDetails( + c.options.sourceActions, + parseAuthorizationDetails(c.options.authorizationDetail), + ); + } catch (error) { + return c.error({ + code: 'INVALID_INPUT', + message: (error as Error).message, + }); + } - yield* pollAuthStatus(authResource, storage, { - interval, - maxAttempts: c.options.maxAttempts, - timeout: c.options.timeout, - }); + // Start from exactly what was requested; if there's a usable session, + // widen it to a superset of the current access. + let scope = requestedScope; + let authorizationDetails: JsonValue[] = requestedAuthorizationDetails; + // Set when we have a live session to widen. The old grant is left intact + // (not cleared, not revoked) until the new approval lands — see below. + let previousRefreshToken: string | undefined; + let warning: string | undefined; + + const existingAuth = storage.getAuth(); + if (existingAuth?.refresh_token) { + try { + const refreshed = await authResource.refreshToken( + existingAuth.refresh_token, + ); + // Persist the rotated tokens so the session stays valid throughout + // the pending approval (and if initiateDeviceAuth below fails). + storage.setAuth(refreshed); + previousRefreshToken = refreshed.refresh_token; + const merged = computeMergedAccess({ + requestedScope, + requestedAuthorizationDetails, + existingScope: refreshed.scope ?? existingAuth.scope, + existingAuthorizationDetails: + refreshed.authorization_details ?? + existingAuth.authorization_details, + }); + scope = merged.mergedScope; + authorizationDetails = merged.mergedAuthorizationDetails; + } catch { + // Existing token is no longer valid — warn and continue with only the + // requested access (per spec, upgrade never hard-fails on this). + // Clear the dead session so the poll isn't short-circuited by it. + storage.clearAuth(); + storage.clearPendingDeviceAuth(); + warning = + 'could not refresh the existing session; continuing with only the requested access.'; + process.stderr.write(`warning: ${warning}\n`); + } + } else { + warning = + 'no active session to upgrade; continuing with only the requested access.'; + process.stderr.write(`warning: ${warning}\n`); + } + + // Unlike `login`, upgrade never bails when already authenticated. It does + // NOT tear down the current session up front: the existing grant stays + // valid (and un-revoked) throughout the pending approval, so a failed + // initiate or an abandoned approval leaves it usable. The pending is + // flagged `replaces_existing_session` so the poll completes the NEW + // approval (rather than short-circuiting on the current token) and revokes + // the old grant only once the widened tokens are stored. + const replacesExistingSession = previousRefreshToken !== undefined; + + if (!c.agent && !c.formatExplicit) { + return renderInteractive( + {}} + />, + () => ({ authenticated: true, token_type: 'Bearer' }), + ); + } + + yield* startDeviceAuthAndPoll( + authResource, + storage, + { clientName, scope, authorizationDetails, replacesExistingSession }, + { + interval: c.options.interval, + maxAttempts: c.options.maxAttempts, + timeout: c.options.timeout, + }, + warning, + ); }, }); diff --git a/packages/cli/src/commands/auth/login.tsx b/packages/cli/src/commands/auth/login.tsx index 34a7e47..329570c 100644 --- a/packages/cli/src/commands/auth/login.tsx +++ b/packages/cli/src/commands/auth/login.tsx @@ -20,6 +20,10 @@ interface LoginProps { sourceActions?: SourceAction[]; authorizationDetails?: JsonValue[]; authStorage?: AuthStorage; + // Set by `auth upgrade`: the still-valid refresh token of the session being + // replaced. Revoked (best-effort) only after the new tokens are stored, so an + // abandoned upgrade leaves the existing session intact. `login` omits it. + revokeRefreshTokenOnSuccess?: string; onComplete: () => void; } @@ -30,6 +34,7 @@ export const Login: React.FC = ({ sourceActions, authorizationDetails, authStorage = defaultStorage, + revokeRefreshTokenOnSuccess, onComplete, }) => { const storage = authStorage; @@ -88,6 +93,14 @@ export const Login: React.FC = ({ if (tokens) { clearInterval(pollInterval); storage.setAuth(tokens); + // Upgrade only: revoke the replaced session's grant now that the + // widened tokens are stored. Best-effort — a failure here must not + // fail the login that just succeeded. + if (revokeRefreshTokenOnSuccess) { + authResource + .revokeToken(revokeRefreshTokenOnSuccess) + .catch(() => {}); + } setStatus('success'); setTimeout(onComplete, DISPLAY_DELAY_MS); } @@ -110,7 +123,14 @@ export const Login: React.FC = ({ // Wait 1 second before starting to poll const timeout = setTimeout(startPolling, 1000); return () => clearTimeout(timeout); - }, [status, deviceCode, authResource, onComplete, storage]); + }, [ + status, + deviceCode, + authResource, + onComplete, + storage, + revokeRefreshTokenOnSuccess, + ]); if (status === 'initiating') { return ( diff --git a/packages/sdk/src/utils/storage.ts b/packages/sdk/src/utils/storage.ts index 2d38cad..6924040 100644 --- a/packages/sdk/src/utils/storage.ts +++ b/packages/sdk/src/utils/storage.ts @@ -9,6 +9,10 @@ export interface PendingDeviceAuth { expires_at: number; verification_url: string; phrase: string; + // Set by `auth upgrade`: this device authorization replaces an existing, + // still-valid session. The poll must complete it (and revoke the old grant + // on success) even though `isAuthenticated()` is currently true. + replaces_existing_session?: boolean; } interface StorageSchema { diff --git a/skills/create-payment-credential/SKILL.md b/skills/create-payment-credential/SKILL.md index 87f7ae7..cfd7b0d 100644 --- a/skills/create-payment-credential/SKILL.md +++ b/skills/create-payment-credential/SKILL.md @@ -104,6 +104,8 @@ DO NOT PROCEED until the user is authenticated with Link. Always check the current authentication status before starting a new login flow — the user might already be logged in. +If the user is already authenticated but you need broader access (an additional `scope`, `--source-actions`, or `--authorization-detail`), use `auth upgrade` instead of `auth login`. It takes the same flags but, rather than stopping with an "already logged in" message, merges what you request with the current `scope`/`authorization_details` and starts a new approval for the superset — so existing access is never dropped. Check `auth status` first so you know what's already granted. The current session stays valid during the approval and is only replaced once the user approves the new one, so an abandoned upgrade leaves the existing session working. + ### Step 2: Evaluate the merchant site BEFORE creating a spend request **CRITICAL:** Before calling `spend-request create` you must complete this checklist: