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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ Input is passed via flags. Define options in the command's zod schema — incur

- `auth login --client-name <name>` — optional flag to identify the agent or app; shown in the user's Link app as `<name> on <hostname>`. Defined in `loginOptions` in `packages/cli/src/commands/auth/schema.ts`.
- `auth login --interval <seconds> [--timeout <seconds>] [--max-attempts <n>]` — when `--interval` is provided, the command yields the verification code immediately then polls inline until authenticated or timed out. Without `--interval`, returns the code with a `_next` hint for separate polling via `auth status`.
- 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.

### spend-request command

Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,11 +207,13 @@ 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 status` includes an `update` field when a newer version is available:
`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
{
"authenticated": true,
"scope": "userinfo:read payment_methods.agentic",
"authorization_details": [{ "type": "source", "actions": ["read"] }],
"update": {
"current_version": "0.1.2",
"latest_version": "0.2.0",
Expand All @@ -220,6 +222,8 @@ With `--interval`, the login command yields the verification code immediately an
}
```

`scope` and `authorization_details` are only present when the token endpoint returned them.

Set `NO_UPDATE_NOTIFIER=1` to suppress update checks (for example, in CI).

All commands accept `--auth <path>` to store auth credentials in a specific file instead of the default location. `auth login` writes to this file; all other commands read from it. Useful for running multiple sessions with separate identities.
Expand Down
46 changes: 46 additions & 0 deletions packages/cli/src/auth/__tests__/auth-resource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,29 @@ describe('LinkAuthResource', () => {
});
});

it('returns scope and authorization_details when present', async () => {
mockFetchResponse(200, {
access_token: 'at_123',
refresh_token: 'rt_456',
expires_in: 3600,
token_type: 'Bearer',
scope: 'userinfo:read',
authorization_details: [{ type: 'source', actions: ['read'] }],
});

const resource = createResource();
const result = await resource.pollDeviceAuth('dev_123');

expect(result).toEqual({
access_token: 'at_123',
refresh_token: 'rt_456',
expires_in: 3600,
token_type: 'Bearer',
scope: 'userinfo:read',
authorization_details: [{ type: 'source', actions: ['read'] }],
});
});

it('returns null when authorization is pending', async () => {
mockFetchResponse(400, { error: 'authorization_pending' });

Expand Down Expand Up @@ -355,6 +378,29 @@ describe('LinkAuthResource', () => {
});
});

it('returns scope and authorization_details when present', async () => {
mockFetchResponse(200, {
access_token: 'at_new',
refresh_token: 'rt_new',
expires_in: 3600,
token_type: 'Bearer',
scope: 'userinfo:read',
authorization_details: [{ type: 'source', actions: ['read'] }],
});

const resource = createResource();
const result = await resource.refreshToken('rt_old');

expect(result).toEqual({
access_token: 'at_new',
refresh_token: 'rt_new',
expires_in: 3600,
token_type: 'Bearer',
scope: 'userinfo:read',
authorization_details: [{ type: 'source', actions: ['read'] }],
});
});

it('throws LinkApiError on failure', async () => {
mockFetchResponse(401, {
error: 'invalid_grant',
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/auth/auth-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ interface TokenResponse {
refresh_token: string;
token_type: string;
expires_in: number;
scope?: string;
authorization_details?: JsonValue[];
}

interface OAuthError {
Expand Down Expand Up @@ -225,6 +227,10 @@ export class LinkAuthResource implements IAuthResource {
refresh_token: resp.refresh_token,
expires_in: resp.expires_in,
token_type: resp.token_type,
...(resp.scope && { scope: resp.scope }),
...(resp.authorization_details && {
authorization_details: resp.authorization_details,
}),
};
}

Expand Down Expand Up @@ -312,6 +318,10 @@ export class LinkAuthResource implements IAuthResource {
refresh_token: resp.refresh_token,
expires_in: resp.expires_in,
token_type: resp.token_type,
...(resp.scope && { scope: resp.scope }),
...(resp.authorization_details && {
authorization_details: resp.authorization_details,
}),
};
}
}
8 changes: 8 additions & 0 deletions packages/cli/src/commands/auth/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ async function* pollAuthStatus(
access_token: `${auth.access_token.substring(0, 20)}...`,
token_type: auth.token_type,
credentials_path: storage.getPath(),
...(auth.scope && { scope: auth.scope }),
...(auth.authorization_details && {
authorization_details: auth.authorization_details,
}),
...(update && { update }),
};
}
Expand Down Expand Up @@ -281,6 +285,10 @@ export function createAuthCli(
token_type: info.tokenType,
...(info.source === 'storage' && {
credentials_path: info.credentialsPath,
...(info.scope && { scope: info.scope }),
...(info.authorizationDetails && {
authorization_details: info.authorizationDetails,
}),
}),
...(update && { update }),
};
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/commands/auth/status.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ export const AuthStatus: React.FC<AuthStatusProps> = ({
<Text>
Token type: <Text bold>{info.tokenType}</Text>
</Text>
{info.source === 'storage' && info.scope && (
<Text>
Scope: <Text bold>{info.scope}</Text>
</Text>
)}
{info.source === 'storage' && info.authorizationDetails && (
<Text>
Authorization details:{' '}
<Text bold>{JSON.stringify(info.authorizationDetails)}</Text>
</Text>
)}
{info.source === 'env' ? (
<Text>
Source: <Text bold>LINK_ACCESS_TOKEN</Text>
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/commands/auth/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AuthStorage } from '@stripe/link-sdk';
import type { AuthStorage, JsonValue } from '@stripe/link-sdk';

export type AuthInfo =
| {
Expand All @@ -13,6 +13,8 @@ export type AuthInfo =
tokenPreview: string;
tokenType: string;
credentialsPath: string;
scope?: string;
authorizationDetails?: JsonValue[];
}
| { authenticated: false; source: 'storage'; credentialsPath: string };

Expand All @@ -37,6 +39,10 @@ export function resolveAuthInfo(
tokenPreview: `${auth.access_token.substring(0, 20)}...`,
tokenType: auth.token_type,
credentialsPath,
...(auth.scope && { scope: auth.scope }),
...(auth.authorization_details && {
authorizationDetails: auth.authorization_details,
}),
};
}
return { authenticated: false, source: 'storage', credentialsPath };
Expand Down
44 changes: 44 additions & 0 deletions packages/sdk/src/resources/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,28 @@ describe('AuthResource', () => {
});
});

it('returns scope and authorization_details when present', async () => {
mockFetchResponse(200, {
access_token: 'at_abc',
refresh_token: 'rt_xyz',
expires_in: 3600,
token_type: 'Bearer',
scope: 'userinfo:read payment_methods.agentic',
authorization_details: [{ type: 'source', actions: ['read'] }],
});

const result = await repo.pollDeviceAuth('dev_123');

expect(result).toEqual({
access_token: 'at_abc',
refresh_token: 'rt_xyz',
expires_in: 3600,
token_type: 'Bearer',
scope: 'userinfo:read payment_methods.agentic',
authorization_details: [{ type: 'source', actions: ['read'] }],
});
});

it('sends correct request parameters', async () => {
mockFetchResponse(200, {
access_token: 'at',
Expand Down Expand Up @@ -426,6 +448,28 @@ describe('AuthResource', () => {
});
});

it('returns scope and authorization_details when present', async () => {
mockFetchResponse(200, {
access_token: 'new_at',
refresh_token: 'new_rt',
expires_in: 7200,
token_type: 'Bearer',
scope: 'userinfo:read',
authorization_details: [{ type: 'source', actions: ['read'] }],
});

const result = await repo.refreshToken('old_rt');

expect(result).toEqual({
access_token: 'new_at',
refresh_token: 'new_rt',
expires_in: 7200,
token_type: 'Bearer',
scope: 'userinfo:read',
authorization_details: [{ type: 'source', actions: ['read'] }],
});
});

it('sends correct request parameters', async () => {
mockFetchResponse(200, {
access_token: 'at',
Expand Down
9 changes: 9 additions & 0 deletions packages/sdk/src/resources/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ interface TokenResponse {
token_type: string;
expires_in: number;
scope?: string;
authorization_details?: JsonValue[];
}

interface OAuthError {
Expand Down Expand Up @@ -253,6 +254,10 @@ export class AuthResource implements IAuthResource {
refresh_token: resp.refresh_token,
expires_in: resp.expires_in,
token_type: resp.token_type,
...(resp.scope && { scope: resp.scope }),
...(resp.authorization_details && {
authorization_details: resp.authorization_details,
}),
};
}

Expand Down Expand Up @@ -338,6 +343,10 @@ export class AuthResource implements IAuthResource {
refresh_token: resp.refresh_token,
expires_in: resp.expires_in,
token_type: resp.token_type,
...(resp.scope && { scope: resp.scope }),
...(resp.authorization_details && {
authorization_details: resp.authorization_details,
}),
};
}
}
4 changes: 4 additions & 0 deletions packages/sdk/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export interface AuthTokens {
token_type: string;
/** Absolute epoch-ms when the access token expires (computed on store). */
expires_at?: number;
/** Space-separated scopes granted for this session (echoed by the token endpoint). */
scope?: string;
/** Authorization details granted for this session (echoed by the token endpoint). */
authorization_details?: JsonValue[];
}

export interface LineItem {
Expand Down
19 changes: 19 additions & 0 deletions packages/sdk/src/utils/__tests__/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,25 @@ describe('MemoryStorage', () => {
expect(stored?.expires_at).toBeGreaterThan(Date.now());
});

it('round-trips scope and authorization_details', () => {
const authStorage = new MemoryStorage();

authStorage.setAuth({
access_token: 'at_123',
refresh_token: 'rt_123',
expires_in: 60,
token_type: 'Bearer',
scope: 'userinfo:read payment_methods.agentic',
authorization_details: [{ type: 'source', actions: ['read'] }],
});

const stored = authStorage.getAuth();
expect(stored?.scope).toBe('userinfo:read payment_methods.agentic');
expect(stored?.authorization_details).toEqual([
{ type: 'source', actions: ['read'] },
]);
});

it('can be initialized with an existing auth session', () => {
const authStorage = new MemoryStorage({
access_token: 'at_123',
Expand Down
2 changes: 1 addition & 1 deletion skills/create-payment-credential/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ Check auth status:
link-cli auth status
```

If the response includes an `update` field, a newer version of `link-cli` is available — run the `update_command` from that field to upgrade before proceeding.
When authenticated, the response also reports the session's granted `scope` and `authorization_details` (when the token endpoint returned them). If the response includes an `update` field, a newer version of `link-cli` is available — run the `update_command` from that field to upgrade before proceeding.

If not authenticated:

Expand Down
Loading