Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
f245c51
feat: add spec-compliant attestation issuance
raubrey-stripe Aug 25, 2026
c31b990
fix: stop requesting AAP representation scope
raubrey-stripe Aug 26, 2026
6cf9426
chore: drop create-payment-credential skill updates
raubrey-stripe Aug 27, 2026
9557634
chore: drop the AAP acronym from attestation docs and CLI
raubrey-stripe Aug 27, 2026
bb626df
chore: keep attestations unlisted unless LINK_IDENTITY_COMMANDS is set
raubrey-stripe Aug 27, 2026
9a6f955
feat: nest attestations under identity with plain-language copy
raubrey-stripe Aug 28, 2026
fde3ee2
fix: match Link generic batch attestation framing
raubrey-stripe Sep 1, 2026
29e5625
feat: add holder-bound identity credential wallet
raubrey-stripe Aug 25, 2026
71d8792
fix: defer credential issuer validation
raubrey-stripe Aug 25, 2026
b2f9019
fix: keep credential token overrides explicit
raubrey-stripe Aug 25, 2026
8250b40
fix: remove obsolete credential scope guidance
raubrey-stripe Aug 26, 2026
ed8b88d
chore: drop the AAP acronym from credential-wallet docs and names
raubrey-stripe Aug 27, 2026
e52a538
chore: keep credentials unlisted unless LINK_IDENTITY_COMMANDS is set
raubrey-stripe Aug 27, 2026
fa118e7
feat: nest credentials under identity as credentials get
raubrey-stripe Aug 28, 2026
d2a6b2b
refactor: centralize the default identity holder key
raubrey-stripe Sep 2, 2026
30d932f
feat: add signed identity-aware HTTP requests
raubrey-stripe Aug 25, 2026
423436a
chore: drop the AAP acronym from request docs and comments
raubrey-stripe Aug 27, 2026
3ab5adc
chore: keep request unlisted unless LINK_IDENTITY_COMMANDS is set
raubrey-stripe Aug 27, 2026
6e6f488
feat: nest identity request under the identity command group
raubrey-stripe Aug 28, 2026
0171396
feat: present pooled attestation tokens on identity request
raubrey-stripe Aug 28, 2026
9dc96e8
feat: prepare identity headers without sending a request
raubrey-stripe Sep 2, 2026
0951b97
chore: align plugin metadata with CLI 0.14.0
raubrey-stripe Sep 2, 2026
ef529d5
Honor Payment-Authorization in mpp pay.
raubrey-stripe Aug 25, 2026
5e98e9a
Restrict mpp pay credentials to Authorization or Payment-Authorization.
raubrey-stripe Aug 25, 2026
4c3bba8
Parse Payment scheme boundaries outside quoted WWW-Authenticate values.
raubrey-stripe Aug 28, 2026
577da36
Use mppx 0.9.1 to parse the Payment credential header.
raubrey-stripe Aug 28, 2026
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
5 changes: 5 additions & 0 deletions .changeset/payment-authorization-header.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stripe/link-cli": patch
---

Honor Payment-Authorization in `mpp pay` so Payment credentials can coexist with ordinary Authorization. Challenges may select only Authorization (default) or Payment-Authorization.
44 changes: 42 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ node packages/cli/dist/cli.js <command>
### SDK Resources

Defined in `packages/sdk/src/resources/interfaces.ts`:
- `IAttestationsResource` — Privacy Pass Blind RSA token issuance
- `ICredentialsResource` — signed user info issuance
- `ISpendRequestResource` — CRUD + request-approval for spend requests

The SDK only accepts credentials. Device authorization, refresh-token
Expand All @@ -50,7 +52,7 @@ Commands in `packages/cli/src/cli.tsx` (incur framework). Each has two output mo
- **Interactive** (default): Ink/React components from `packages/cli/src/commands/`
- **JSON** (`--format json`): JSON to stdout, errors as JSON with `code` and `message` fields with exit code 1

Commands: `auth login|logout|status`, `spend-request create|update|retrieve|request-approval|cancel`, `payment-methods list`, `shipping-address list`, `mpp pay|decode`, `serve`.
Commands: `auth login|logout|status`, `spend-request create|update|retrieve|request-approval|cancel`, `payment-methods list`, `shipping-address list`, `mpp pay|decode`, `identity attestations request`, `identity credentials get`, `identity request`, `serve`.

The CLI also runs as an MCP server (`--mcp`) and serves skill files via `skills` subcommand, both provided by incur.

Expand Down Expand Up @@ -108,6 +110,43 @@ Key input field notes:

- `onboard` — Guided setup: authenticates (skips if already logged in), checks payment methods (prompts to add one if missing, shows picker if multiple), shows app download QR code, then runs the full demo. Requires a TTY.

### identity attestations command

Unlisted: omitted from `--help`, `--llms`, and MCP tool lists unless `LINK_IDENTITY_COMMANDS=1` (or `true`). Even when enabled, the command sets `mcp: false` so MCP clients do not see it.

`identity attestations request --count <n> [--issuer <url>] [--access-token <t>] [--pool-file <path>]` — gets privacy-preserving tokens that show Link attests to your agent. Agent-only output. The SDK owns issuance in `packages/sdk/src/resources/attestations.ts` and `attestations-crypto.ts`; CLI schema and registration remain in `packages/cli/src/commands/attestations/`, mounted under `packages/cli/src/commands/identity/`.

- Discovery: `GET <issuer>/.well-known/aap-issuer` → metadata, then `GET` its `token_keys` URL. The issuer and every discovered endpoint must use HTTPS on the same DNS origin; redirects and IP-literal hosts are rejected before credentials are sent.
- Tokens use a stable challenge: fixed `issuer_name`, empty `redemption_context`, and empty `origin_info`.
- Blind signatures are verified after unblinding before final tokens are returned.
- Unused tokens are persisted to `--pool-file` (default `~/.link/aat-pool.json`, mode 0600) so `identity request` can answer `PrivateToken` challenges without a synchronous issuance round-trip.
- Server-side max batch is 100. Issuance does not require an additional OAuth scope.
- Auth: `--access-token`, else stored CLI credentials.

### identity credentials command

Unlisted: omitted from `--help`, `--llms`, and MCP tool lists unless `LINK_IDENTITY_COMMANDS=1` (or `true`). Even when enabled, the command sets `mcp: false` so MCP clients do not see it.

`identity credentials get [--key-file <path>] [--key-type ed25519|p256] [--access-token <t>]` — gets signed user info proving it comes from Link (a wallet of claims such as name, email, and phone). Agent-only output. The SDK discovers and calls `credential_endpoint`; the CLI owns local key persistence, claim decoding, schema, and command registration under `packages/cli/src/commands/identity/`.

- Discovery uses `GET <issuer>/.well-known/aap-issuer`, where `<issuer>` is `LINK_API_BASE_URL` or `https://api.link.com`. The metadata `issuer` and `credential_endpoint` must remain on that HTTPS DNS origin.
- `POST <credential_endpoint>` sends `{"cnf":{"jwk":<public JWK>}}`. Only the public Ed25519 or P-256 members are sent.
- The private key is persisted at `--key-file` (default `~/.link/holder-key.jwk`, mode 0600) and reused across runs.
- Requires `userinfo:read` and `payment_methods.agentic`; no additional OAuth scope is required.

### identity request command

Unlisted: omitted from `--help`, `--llms`, and MCP tool lists unless `LINK_IDENTITY_COMMANDS=1` (or `true`). Even when enabled, the command sets `mcp: false` so MCP clients do not see it.

`identity request <url> [--claims "a,b,c"] [-X <method>] [-d <body>] [-H <header>]... [--key-file <path>] [--key-type ed25519|p256] [--pool-file <path>]` — makes an HTTPS request and, if the site asks for attestation or who you are, presents a pooled Link attestation token and signed user info. HTTPS is required except for loopback development.

- Recognizes a `PrivateToken` challenge (`WWW-Authenticate: PrivateToken challenge=..., token-key=...`) and answers from `--pool-file` with `Authorization: PrivateToken token=...`. Tokens are single-use; an empty or non-matching pool fails closed (`AAT_POOL_EMPTY` / `AAT_NO_MATCH`).
- Recognizes a claims challenge only when status is 401, `WWW-Authenticate` includes `Identity-Presentation`, content type is `application/problem+json`, and the body type is `urn:aap:claims-required`. Combined `401`s (PrivateToken + Identity-Presentation) are answered in one retry.
- Requires the challenge `aud` to exactly equal the request origin, supports `dc+sd-jwt`, and honors `trusted_issuers`.
- Supports string and nested claims path pointers. `sd_hash` uses the credential's `_sd_alg` (default `sha-256`).
- The retry uses a request-specific HTTP Message Signature covering method, authority, path, `Signature-Agent`, `Identity-Presentation`, any `Authorization`, and `Content-Digest` when a body is present.
- Redirects are not followed, preventing identity presentations or authorization credentials from crossing origins.

### serve command

- `serve [--port <n>] [--host <host>]` — HTTP server that exposes the CLI's MCP endpoint. Implemented in `packages/cli/src/commands/serve/index.ts`. The handler forwards to `rootCli.fetch()` (incur), but is a **privilege boundary**: `requireAuth` only proves the CLI *owner* is authenticated, not that the HTTP caller is authorized.
Expand Down Expand Up @@ -135,7 +174,7 @@ Server-returned strings can contain ANSI escape sequences or control characters
- **SDK-resource data** — sanitized automatically at the `sanitizeResource()` proxy boundary in `packages/cli/src/utils/resource-factory.ts`. All server data flowing through SDK resources (spend-request, payment-methods, sources, etc.) is `sanitizeDeep()`'d before reaching components or the incur formatter, in every output format.
- **Commands using `useAsyncAction` hook** — sanitized automatically. The hook calls `sanitizeDeep()` on all returned data before it reaches components.
- **Commands with manual state management** (e.g. `create.tsx`, `retrieve.tsx`, `request-approval.tsx`, `mpp/pay.tsx`) — must call `sanitizeDeep()` on API responses before calling `setRequest()`/`setState()`.
- **Attacker-controlled data that does NOT flow through an SDK resource** — must be sanitized at its own parse boundary. `mpp pay` sanitizes the HTTP response in `readPayResult()` (`pay.tsx`); `mpp decode` sanitizes the parsed `WWW-Authenticate` challenge in `decodeStripeChallenge()` (`decode.ts`). These bypass the resource factory, so the return value of the parse/fetch helper is the chokepoint — sanitizing there covers both the interactive Ink render and the agent (toon/yaml/md) output at once.
- **Attacker-controlled data that does NOT flow through an SDK resource** — must be sanitized at its own parse boundary. `mpp pay` sanitizes the HTTP response in `readPayResult()` (`pay.tsx`); `mpp decode` sanitizes the parsed `WWW-Authenticate` challenge in `decodeStripeChallenge()` (`decode.ts`); `request` sanitizes claims challenges in `parseClaimsChallenge()` and merchant response bodies in `parseBody()`. These bypass the resource factory, so the return value of the parse/fetch helper is the chokepoint — sanitizing there covers both the interactive Ink render and the agent (toon/yaml/md) output at once.

JSON output mode (`--format json`) is **not** affected — `JSON.stringify` encodes escape sequences as Unicode literals.
## Environment Variables
Expand All @@ -149,3 +188,4 @@ JSON output mode (`--format json`) is **not** affected — `JSON.stringify` enco
| `LINK_API_BASE_URL` | Override API base URL |
| `LINK_AUTH_BASE_URL` | Override auth base URL |
| `LINK_HTTP_PROXY` | Route all SDK requests through an HTTP proxy (requires `undici` installed) |
| `LINK_IDENTITY_COMMANDS` | When `1` or `true`, register the unlisted `identity` command group. Omitted from `--help`, `--llms`, and MCP otherwise. |
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,37 @@ 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.

### Identity

Unlisted commands: set `LINK_IDENTITY_COMMANDS=1` to enable them. They are omitted from `--help`, `--llms`, and MCP tool lists otherwise.

Privacy-preserving tokens that show Link attests to your agent:

```bash
LINK_IDENTITY_COMMANDS=1 link-cli identity attestations request --count 10
LINK_IDENTITY_COMMANDS=1 link-cli identity attestations request --count 10 --issuer https://api.link.com
```

`identity attestations request` asks Link for a pool of those tokens (`--count` 1–100) and stores unused tokens in `~/.link/aat-pool.json` for `identity request`. You can pass an HTTPS `--issuer` and an `--access-token`; otherwise stored login credentials are used. Issuer discovery and issuance stay on the issuer's HTTPS DNS origin; redirects and IP-literal hosts are rejected.

User info that has been signed, proving it comes from Link:

```bash
LINK_IDENTITY_COMMANDS=1 link-cli identity credentials get
LINK_IDENTITY_COMMANDS=1 link-cli identity credentials get --key-file ~/.link/holder-key.jwk --key-type ed25519
```

`identity credentials get` fetches that signed user info and keeps a local key so you can present the same wallet of claims later. Link tells the CLI where to request it; there is no fixed path to hard-code.

If a site asks for attestation or who you are, present a pooled attestation token and signed user info on an HTTPS request:

```bash
LINK_IDENTITY_COMMANDS=1 link-cli identity request https://merchant.example/checkout
LINK_IDENTITY_COMMANDS=1 link-cli identity request https://merchant.example/checkout --claims email,given_name
```

`identity request` sends the HTTP request. When the site challenges with `PrivateToken`, it spends one token from the local pool. When the site asks for signed user info, it gets that from Link, shares only the requested fields, and retries. Both challenges can appear on the same `401`. The retry is signed with a request-specific Web Bot Auth HTTP Message Signature covering the presented token and user info. Redirects are not followed.

### Spend request lifecycle

A spend request moves through: **create** → **request approval** → **approved** (with credentials).
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"incur": "^0.4.26",
"ink": "^5.2.1",
"ink-spinner": "^5.0.0",
"mppx": "0.8.15",
"mppx": "0.9.1",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"strip-ansi": "^7.2.0",
Expand Down
47 changes: 46 additions & 1 deletion packages/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2448,7 +2448,7 @@ describe('production mode', () => {
].join(' ');

function decodeCredential(authorizationHeader: string): {
challenge: { intent: string };
challenge: { intent: string; header?: string };
payload: Record<string, unknown>;
} {
const encoded = authorizationHeader.replace(/^Payment\s+/i, '');
Expand Down Expand Up @@ -2483,6 +2483,51 @@ describe('production mode', () => {
expect(merchantRequests[1].headers.authorization).toMatch(/^Payment /);
});

it('retries with Payment-Authorization when the challenge advertises that header', async () => {
const wwwAuthenticate = [
'Payment id="ch_001",',
'realm="127.0.0.1",',
'method="stripe",',
'intent="charge",',
'header="Payment-Authorization",',
`request="${Buffer.from(JSON.stringify({ networkId: 'net_001', amount: '1000', currency: 'usd', decimals: 2, paymentMethodTypes: ['card'] })).toString('base64')}",`,
'expires="2099-01-01T00:00:00Z"',
].join(' ');

setNextResponse(200, APPROVED_SPT_REQUEST);
setMerchantResponse(402, '{"error":"payment required"}', {
'www-authenticate': wwwAuthenticate,
});
setMerchantResponse(200, '{"success":true}');

const result = await runProdCli(
'mpp',
'pay',
`http://127.0.0.1:${merchantPort}/api/charge`,
'--spend-request-id',
'lsrq_spt_001',
'--header',
'Authorization: Bearer app-token',
'--json',
);

expect(result.exitCode).toBe(0);
expect(merchantRequests).toHaveLength(2);
expect(merchantRequests[1].headers.authorization).toBe(
'Bearer app-token',
);
expect(merchantRequests[1].headers['payment-authorization']).toMatch(
/^Payment /,
);
const credential = decodeCredential(
merchantRequests[1].headers['payment-authorization'] as string,
);
expect(credential.challenge).toMatchObject({
intent: 'charge',
header: 'Payment-Authorization',
});
});

it('returns structured response when the paid retry fails', async () => {
setNextResponse(200, APPROVED_SPT_REQUEST);
setMerchantResponse(402, '{"error":"payment required"}', {
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type CliAuthStorage, Storage, storage } from './auth/storage';
import { createAuthCli } from './commands/auth';
import { createBalancesCli } from './commands/balances';
import { createDemoCli } from './commands/demo';
import { createIdentityCli } from './commands/identity';
import { createMppCli } from './commands/mpp';
import { createOnboardCli } from './commands/onboard';
import { createPaymentMethodsCli } from './commands/payment-methods';
Expand Down Expand Up @@ -88,6 +89,23 @@ if (!isAgent && process.stdout.isTTY) {
}
}

const identityCommandsEnabled =
process.env.LINK_IDENTITY_COMMANDS === '1' ||
process.env.LINK_IDENTITY_COMMANDS === 'true';

if (identityCommandsEnabled) {
cli.command(
createIdentityCli({
createAttestationsResource: (accessToken) =>
factory.createAttestationsResource(accessToken),
createCredentialsResource: (accessToken) =>
factory.createCredentialsResource(accessToken),
createWebBotAuthResource: () => factory.createWebBotAuthResource(),
authStorage,
envAccessToken,
}),
);
}
cli.command(
createAuthCli(authRepo, getUpdateInfo, authStorage, envAccessToken),
);
Expand Down
39 changes: 39 additions & 0 deletions packages/cli/src/commands/attestations/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { IAttestationsResource } from '@stripe/link-sdk';
import { Cli } from 'incur';
import { requestOptions } from './schema';

export function createAttestationsCli(
createResource: (accessToken?: string) => IAttestationsResource,
) {
const cli = Cli.create('attestations', {
description:
'A privacy-preserving token that shows Link attests to your agent.',
});

cli.command('request', {
description:
'Get privacy-preserving tokens that show Link attests to your agent.',
options: requestOptions,
mcp: false,
outputPolicy: 'agent-only' as const,
async run(c) {
const { count, issuer, accessToken, poolFile } = c.options;
const { remainingCount, saveIssuedTokens } = await import('./pool');

const result = await createResource(accessToken).request({
issuer,
count,
});
saveIssuedTokens(poolFile, result);
return {
...result,
pool: {
path: poolFile,
remaining: remainingCount(poolFile),
},
};
},
});

return cli;
}
74 changes: 74 additions & 0 deletions packages/cli/src/commands/attestations/pool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { remainingCount, saveIssuedTokens, takeMatchingToken } from './pool';

function fakeToken(challengeDigest: Buffer, tokenKeyId: Buffer): string {
const raw = Buffer.alloc(2 + 32 + 32 + 32 + 8);
raw.writeUInt16BE(0x0002, 0);
challengeDigest.copy(raw, 34);
tokenKeyId.copy(raw, 66);
return raw.toString('base64url');
}

describe('attestation token pool', () => {
const directories: string[] = [];

afterEach(() => {
for (const directory of directories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});

function poolPath(): string {
const directory = mkdtempSync(join(tmpdir(), 'link-cli-aat-'));
directories.push(directory);
return join(directory, 'aat-pool.json');
}

it('appends issued tokens and pops a matching one', () => {
const path = poolPath();
const digest = Buffer.alloc(32, 7);
const keyId = Buffer.alloc(32, 9);
const first = fakeToken(digest, keyId);
const second = fakeToken(digest, keyId);

saveIssuedTokens(path, {
issuer: 'https://api.link.com',
token_key_id: keyId.toString('base64url'),
tokens: [first, second],
});
expect(remainingCount(path)).toBe(2);

const spent = takeMatchingToken(path, {
challengeDigest: new Uint8Array(digest),
tokenKeyId: new Uint8Array(keyId),
});
expect(spent).toEqual({
token: first,
issuer: 'https://api.link.com',
remaining: 1,
});
expect(remainingCount(path)).toBe(1);
});

it('returns null when the challenge digest does not match', () => {
const path = poolPath();
const digest = Buffer.alloc(32, 1);
const keyId = Buffer.alloc(32, 2);
saveIssuedTokens(path, {
issuer: 'https://api.link.com',
token_key_id: keyId.toString('base64url'),
tokens: [fakeToken(digest, keyId)],
});

expect(
takeMatchingToken(path, {
challengeDigest: new Uint8Array(Buffer.alloc(32, 3)),
tokenKeyId: new Uint8Array(keyId),
}),
).toBeNull();
expect(remainingCount(path)).toBe(1);
});
});
Loading