From f3122236b5bd2425bb2cf5668579e4a12c1c049b Mon Sep 17 00:00:00 2001 From: System Administrator Date: Mon, 20 Jul 2026 12:59:47 -0700 Subject: [PATCH] feat: persist and display oauth scope + auth details Committed-By-Agent: claude --- CLAUDE.md | 2 + README.md | 6 ++- .../src/auth/__tests__/auth-resource.test.ts | 46 +++++++++++++++++++ packages/cli/src/auth/auth-resource.ts | 10 ++++ packages/cli/src/commands/auth/index.tsx | 8 ++++ packages/cli/src/commands/auth/status.tsx | 11 +++++ packages/cli/src/commands/auth/utils.ts | 8 +++- .../sdk/src/resources/__tests__/auth.test.ts | 44 ++++++++++++++++++ packages/sdk/src/resources/auth.ts | 9 ++++ packages/sdk/src/types/index.ts | 4 ++ .../sdk/src/utils/__tests__/storage.test.ts | 19 ++++++++ skills/create-payment-credential/SKILL.md | 2 +- 12 files changed, 166 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 46d0bc6..b9eaaff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,8 @@ Input is passed via flags. Define options in the command's zod schema — incur - `auth login --client-name ` — optional flag to identify the agent or app; shown in the user's Link app as ` on `. Defined in `loginOptions` in `packages/cli/src/commands/auth/schema.ts`. - `auth login --interval [--timeout ] [--max-attempts ]` — 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 diff --git a/README.md b/README.md index 607f308..e1ee7f4 100644 --- a/README.md +++ b/README.md @@ -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", @@ -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 ` 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. diff --git a/packages/cli/src/auth/__tests__/auth-resource.test.ts b/packages/cli/src/auth/__tests__/auth-resource.test.ts index 6085b07..69615af 100644 --- a/packages/cli/src/auth/__tests__/auth-resource.test.ts +++ b/packages/cli/src/auth/__tests__/auth-resource.test.ts @@ -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' }); @@ -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', diff --git a/packages/cli/src/auth/auth-resource.ts b/packages/cli/src/auth/auth-resource.ts index 362afe6..11f3893 100644 --- a/packages/cli/src/auth/auth-resource.ts +++ b/packages/cli/src/auth/auth-resource.ts @@ -36,6 +36,8 @@ interface TokenResponse { refresh_token: string; token_type: string; expires_in: number; + scope?: string; + authorization_details?: JsonValue[]; } interface OAuthError { @@ -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, + }), }; } @@ -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, + }), }; } } diff --git a/packages/cli/src/commands/auth/index.tsx b/packages/cli/src/commands/auth/index.tsx index 37a6ed4..dd771a5 100644 --- a/packages/cli/src/commands/auth/index.tsx +++ b/packages/cli/src/commands/auth/index.tsx @@ -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 }), }; } @@ -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 }), }; diff --git a/packages/cli/src/commands/auth/status.tsx b/packages/cli/src/commands/auth/status.tsx index e69f4cd..c375b6c 100644 --- a/packages/cli/src/commands/auth/status.tsx +++ b/packages/cli/src/commands/auth/status.tsx @@ -40,6 +40,17 @@ export const AuthStatus: React.FC = ({ Token type: {info.tokenType} + {info.source === 'storage' && info.scope && ( + + Scope: {info.scope} + + )} + {info.source === 'storage' && info.authorizationDetails && ( + + Authorization details:{' '} + {JSON.stringify(info.authorizationDetails)} + + )} {info.source === 'env' ? ( Source: LINK_ACCESS_TOKEN diff --git a/packages/cli/src/commands/auth/utils.ts b/packages/cli/src/commands/auth/utils.ts index d461fdc..3c780af 100644 --- a/packages/cli/src/commands/auth/utils.ts +++ b/packages/cli/src/commands/auth/utils.ts @@ -1,4 +1,4 @@ -import type { AuthStorage } from '@stripe/link-sdk'; +import type { AuthStorage, JsonValue } from '@stripe/link-sdk'; export type AuthInfo = | { @@ -13,6 +13,8 @@ export type AuthInfo = tokenPreview: string; tokenType: string; credentialsPath: string; + scope?: string; + authorizationDetails?: JsonValue[]; } | { authenticated: false; source: 'storage'; credentialsPath: string }; @@ -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 }; diff --git a/packages/sdk/src/resources/__tests__/auth.test.ts b/packages/sdk/src/resources/__tests__/auth.test.ts index ceaab5c..790d375 100644 --- a/packages/sdk/src/resources/__tests__/auth.test.ts +++ b/packages/sdk/src/resources/__tests__/auth.test.ts @@ -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', @@ -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', diff --git a/packages/sdk/src/resources/auth.ts b/packages/sdk/src/resources/auth.ts index 5a871f5..a410348 100644 --- a/packages/sdk/src/resources/auth.ts +++ b/packages/sdk/src/resources/auth.ts @@ -31,6 +31,7 @@ interface TokenResponse { token_type: string; expires_in: number; scope?: string; + authorization_details?: JsonValue[]; } interface OAuthError { @@ -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, + }), }; } @@ -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, + }), }; } } diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts index a9c9c68..aa60c9b 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -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 { diff --git a/packages/sdk/src/utils/__tests__/storage.test.ts b/packages/sdk/src/utils/__tests__/storage.test.ts index e04b946..d9cd4cd 100644 --- a/packages/sdk/src/utils/__tests__/storage.test.ts +++ b/packages/sdk/src/utils/__tests__/storage.test.ts @@ -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', diff --git a/skills/create-payment-credential/SKILL.md b/skills/create-payment-credential/SKILL.md index ecff6e2..87f7ae7 100644 --- a/skills/create-payment-credential/SKILL.md +++ b/skills/create-payment-credential/SKILL.md @@ -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: