diff --git a/ccip-api-ref/docs-cli/configuration.mdx b/ccip-api-ref/docs-cli/configuration.mdx index 35e1b7a99..dbc8c7843 100644 --- a/ccip-api-ref/docs-cli/configuration.mdx +++ b/ccip-api-ref/docs-cli/configuration.mdx @@ -142,7 +142,7 @@ Pass directly or specify a file path: | Aptos | Hex private key, or path to text file containing it | | Sui | Hex or base64 private key | | TON | 64-byte hex private key (`0x`-prefixed), mnemonic phrase (space-separated), path to key file, or `ledger[:index]` | -| Canton | 64-character hex Ed25519 seed (optional; party ID comes from `--canton-config`) | +| Canton | Not required — party ID comes from `--canton-config`; transactions submit via JWT | ### Foundry Cast Keystore @@ -243,33 +243,34 @@ Canton operations require a JSON config file passed via `--canton-config ` ```json { - "party": "sender::1220...", - "ccipParty": "ccip::1220...", - "jwt": "eyJ...", + "party": "u_7c1f39da042a::1220c250c23c...", + "ccipParty": "ccipOwner::1220e382f4e5...", + "auth": { + "type": "clientCredentials", + "authUrl": "https://auth.example.com/oauth2/default" + }, "edsUrl": "https://eds.example.com", "transferInstructionUrl": "https://transfer-instruction.example.com", - "externalEdsUrlsByOwner": { - "owner::1220...": "https://external-eds.example.com" - }, "indexerUrl": "https://indexer.example.com", "chainId": "canton:TestNet", - "senderInstanceId": "ccipsender", - "defaultSendGasLimit": 50000, - "feeTransferFactoryAmount": "1.0", - "ccvs": ["instanceId@party::1220..."], "packages": { - "perPartyRouter": "ccip-runtime", - "ccipSender": "ccip-sender", - "ccipReceiver": "ccip-receiver" - } + "perPartyRouter": "ccip-runtime-v2", + "ccipReceiver": "ccip-receiver-v2", + "ccipSender": "ccip-sender-v2" + }, + "senderInstanceId": "prod-ccipsender", + "ccvs": ["0x5b92820da106..."] } ``` +Set `CANTON_CLIENT_ID` and `CANTON_CLIENT_SECRET` env vars for the `clientCredentials` flow, or use `"jwt": "eyJ..."` in place of the `auth` block for a pre-obtained token. See [Canton Authentication](#canton-authentication) for all flows. + | Field | Required | Description | | -------------------------- | -------- | -------------------------------------------------------------------------------- | | `party` | Yes | User ledger party for actAs and transaction visibility | | `ccipParty` | Yes | CCIP operator party (CCIPSender signatory / fee recipient) | -| `jwt` | Yes | JSON Web Token for Canton Ledger API authentication | +| `jwt` | No\* | JSON Web Token for Canton Ledger API authentication | +| `auth` | No\* | OIDC auth config (alternative to `jwt` — see [below](#canton-authentication)) | | `edsUrl` | Yes | Base URL for the Explicit Disclosure Service (EDS) | | `transferInstructionUrl` | Yes | Base URL for the Transfer Instruction API | | `externalEdsUrlsByOwner` | No | Map of owner party → external EDS URL | @@ -281,22 +282,79 @@ Canton operations require a JSON config file passed via `--canton-config ` | `ccvs` | No | CCV instance addresses for execute disclosures and send defaults | | `packages` | No | DAR package names for ACS template filters | +\* Either `jwt` or `auth` is required. If both are present, `jwt` takes precedence. + +> [!NOTE] +> The top-level `jwt` field is shorthand for `auth: { type: "static", jwt }` — both are equivalent. If both are present, `jwt` takes precedence. + +### Canton Authentication {#canton-authentication} + +The `auth` object supports three flows. The `static` flow wraps a pre-obtained JWT (equivalent to the top-level `jwt` field); `clientCredentials` and `authorizationCode` obtain a JWT automatically via [OpenID Connect (OIDC)](https://openid.net/connect/): + +| `auth.type` | Use case | Required `auth` fields | +| ------------------- | -------------------------------- | --------------------------------------- | +| `static` | Pre-obtained JWT | `jwt` | +| `clientCredentials` | Machine-to-machine (CI/CD) | `authUrl`, `clientId`†, `clientSecret`† | +| `authorizationCode` | Interactive browser login (PKCE) | `authUrl`, `clientId`† | + +† `clientId` and `clientSecret` may be omitted from the config file and resolved from `CANTON_CLIENT_ID` / `CANTON_CLIENT_SECRET` env vars instead. **Keep secrets in env vars, not in config files.** + +**Client credentials example** (CI/CD, machine-to-machine): + +```json +{ + "auth": { + "type": "clientCredentials", + "authUrl": "https://auth.example.com" + } +} +``` + +```bash +export CANTON_CLIENT_ID="my-client-id" +export CANTON_CLIENT_SECRET="my-client-secret" +``` + +**Authorization code example** (interactive browser login for human users): + +```json +{ + "auth": { + "type": "authorizationCode", + "authUrl": "https://auth.example.com", + "callbackUrl": "http://localhost:8400/callback" + } +} +``` + +```bash +export CANTON_CLIENT_ID="my-client-id" +``` + +The `authorizationCode` flow opens a browser for login and starts a local callback server to receive the authorization code. The `callbackUrl` defaults to `http://localhost:8400/callback` — override it if port 8400 is in use or your OIDC provider requires a different redirect URI. PKCE with S256 is required. + +When `auth` is set, the CLI resolves a JWT upfront (before connecting to the ledger) via the SDK's runtime-agnostic OAuth 2.0 protocol helpers (backed by [`oauth4webapi`](https://github.com/panva/oauth4webapi)). The SDK itself never orchestrates an OAuth flow — it only consumes what it's given (`jwt`, which accepts either a string or a `() => Promise` getter). For `clientCredentials` and `authorizationCode`, the CLI injects a getter so tokens are refreshed automatically per request. Optional `auth` fields: `audience` (Auth0-specific), `scopes` (defaults to `daml_ledger_api` for client credentials, `openid daml_ledger_api` for authorization code), `callbackUrl` (authorization code only, defaults to `http://localhost:8400/callback`). + +> [!NOTE] +> The `authorizationCode` flow is orchestrated by the CLI: it starts a local callback server (`node:http`) and opens the default browser (`open`/`xdg-open`). These Node-specific steps live in the CLI, not the SDK, so the SDK stays runtime-agnostic (no `node:*` imports) and can be embedded in web/Electron apps. Web embedders compose the SDK's protocol helpers (`buildAuthorizationRequest`, `validateAuthorizationCallback`, `exchangeAuthorizationCode`) with their own redirect/callback handling. + ### Canton Wallet -On Canton, the party ID comes from config. For external signing, pass a 64-character hex Ed25519 seed via `--wallet`: +On Canton, the party ID comes from config and transactions are submitted directly using the JWT from config (either the static `jwt` field or a token resolved from `auth`). The `--wallet` flag is not required for Canton operations. ```bash -ccip-cli send canton-testnet ethereum-testnet-sepolia \ +ccip-cli send \ + -s canton-testnet \ + -d ethereum-testnet-sepolia \ + -r prod-ccipsender \ --canton-config ./canton-config.json \ --rpc https://ledger.example.com/api/json \ --rpc https://ethereum-sepolia-rpc.example.com \ - -r ccipsender \ - -w <64-char-ed25519-seed> \ - -t link-token=1.0 + --to 0xReceiverContract \ + -t link-token=1.0 \ + --no-estimate-gas-limit ``` -Without `--wallet`, the CLI submits transactions directly using the JWT from config. - ## Shell Completion Enable tab-completion for commands and options by adding the completion script to your shell profile: diff --git a/ccip-api-ref/docs-cli/manual-exec.mdx b/ccip-api-ref/docs-cli/manual-exec.mdx index cfea953ff..630cd7fe1 100644 --- a/ccip-api-ref/docs-cli/manual-exec.mdx +++ b/ccip-api-ref/docs-cli/manual-exec.mdx @@ -158,19 +158,34 @@ ccip-cli manual-exec 0xabc123... \ --receiver-object-ids 0xabc... 0xdef... ``` -### Canton execution by update ID +### Canton source by update ID -For Canton destinations, pass the ledger update ID from the source send transaction: +When the **source is Canton**, pass the ledger update ID from the Canton send transaction: ```bash ccip-cli manual-exec 1220a1b2c3d4... \ --canton-config ./canton-config.json \ --rpc https://ledger.example.com/api/json \ - --receiver sender::1220... \ - --wallet <64-char-ed25519-seed> + --rpc https://ethereum-sepolia-rpc.example.com \ + --wallet ledger ``` -Canton manual execution requires `--canton-config` with `indexerUrl` (or `--indexer`) for CCV verifications. The `--receiver` flag accepts a CCIPReceiver contract ID, a party ID, or the keccak256 hash from the message receiver field. +The `--wallet` flag is required when the destination is EVM (or another chain) — to sign the execution transaction. When the destination is Canton, `--wallet` is not needed — the JWT from `--canton-config` (either the static `jwt` field or a token resolved from `auth`) is used instead. + +When the source is EVM (or another chain), pass the source transaction hash as usual — the CLI auto-detects the format. You can also use a CCIP message ID (32-byte hex) with `--api` enabled (the default), which fetches execution inputs from the CCIP API without needing source chain RPC access. + +Canton manual execution requires `--canton-config` with `indexerUrl` (or `--indexer`) for CCV verifications. When the destination is Canton, use `--receiver` to specify a CCIPReceiver contract ID, party ID (`hint::1220…`), or keccak256(party) hash — it defaults to the message receiver from the original CCIP request. + +### Canton destination execution + +When the **destination is Canton**, `--wallet` is not needed — the JWT from `--canton-config` (either the static `jwt` field or a token resolved from `auth`) is used to submit the execution transaction. Pass the source transaction hash or CCIP message ID (use [`ccip-cli show`](/cli/show) to find it): + +```bash +ccip-cli manual-exec 0x1234... \ + --canton-config ./canton-config.json \ + --rpc https://ledger.example.com/api/json \ + --rpc https://ethereum-sepolia-rpc.example.com +``` ## Execution Flow @@ -204,7 +219,7 @@ After successful execution, buffers auto-clear. Lookup tables require a grace pe ## Canton Considerations -Canton execution uses the ledger's interactive submission flow (prepare → sign → execute) when an external signer is provided via `--wallet`. Without `--wallet`, transactions are submitted directly using the JWT from config. +Canton execution submits transactions directly using the JWT from config (either the static `jwt` field or a token resolved from `auth`). | Input | Format | | --------- | ----------------------------------------------------------------------- | diff --git a/ccip-api-ref/docs-cli/send.mdx b/ccip-api-ref/docs-cli/send.mdx index 8d9346a60..f52d2acb5 100644 --- a/ccip-api-ref/docs-cli/send.mdx +++ b/ccip-api-ref/docs-cli/send.mdx @@ -244,17 +244,19 @@ ccip-cli send \ ### Canton to EVM +On Canton source, `--wallet` is not needed — the JWT from `--canton-config` (either the static `jwt` field or a token resolved from `auth`) is used to submit the send transaction: + ```bash ccip-cli send \ -s canton-testnet \ -d ethereum-testnet-sepolia \ - -r ccipsender \ + -r prod-ccipsender \ --canton-config ./canton-config.json \ --rpc https://ledger.example.com/api/json \ --rpc https://ethereum-sepolia-rpc.example.com \ --to 0xReceiverContract \ -t link-token=1.0 \ - --estimate-gas-limit -101 + --no-estimate-gas-limit ``` On Canton source, `-r` is the **CCIPSender instance id** (not an EVM router address). Use `--only-get-fee` to preview — Canton returns `0` since fees are computed on-ledger. Pass Canton send extras via `--extra`: diff --git a/ccip-api-ref/docs-sdk/guides/manual-execution.mdx b/ccip-api-ref/docs-sdk/guides/manual-execution.mdx index ed1d973cc..7dd6402c0 100644 --- a/ccip-api-ref/docs-sdk/guides/manual-execution.mdx +++ b/ccip-api-ref/docs-sdk/guides/manual-execution.mdx @@ -290,38 +290,44 @@ Unlike `generateUnsignedSendMessage` (which may return multiple transactions for ## Canton Execution -Canton manual execution requires a `CantonConfig` with `indexerUrl` for CCV verifications. The destination chain must be connected with `CantonChain.fromUrl`: +Canton manual execution requires a `CantonConfig` with `indexerUrl` for CCV verifications. The destination chain must be connected with `CantonChain.fromUrl`. No private key or signer is needed — the JWT from `cantonConfig` authenticates the submission. The `wallet` parameter only needs a `party` ID (for `actAs` in command submissions) — this is the same `party` already specified in `cantonConfig`: ```typescript -import { CantonChain } from '@chainlink/ccip-sdk' +import { CantonChain, type CantonConfig } from '@chainlink/ccip-sdk' + +const cantonConfig: CantonConfig = { + party: 'receiver::1220...', + ccipParty: 'ccip::1220...', + // Either a static JWT string, or a () => Promise getter for + // refreshable tokens (OAuth2). The CLI resolves its `auth` block into + // one of these upfront — see Canton Configuration. Web/Electron embedders + // compose the SDK's OAuth2 protocol helpers and inject a getter here. + jwt: 'eyJ...', + edsUrl: 'https://eds.example.com', + transferInstructionUrl: 'https://transfer-instruction.example.com', + indexerUrl: 'https://indexer.example.com', +} const dest = await CantonChain.fromUrl('https://ledger.example.com/api/json', { - cantonConfig: { - party: 'receiver::1220...', - ccipParty: 'ccip::1220...', - jwt: 'eyJ...', - edsUrl: 'https://eds.example.com', - transferInstructionUrl: 'https://transfer-instruction.example.com', - indexerUrl: 'https://indexer.example.com', - }, + cantonConfig, }) // Execute by message ID (CCIP API provides verifications) const execution = await dest.execute({ messageId: '0x1234...abcd', - wallet: cantonWallet, + wallet: { party: cantonConfig.party }, // Canton wallet — reuses the config party, no signer needed }) // Or execute with pre-fetched input (CCIP v2.0 only) const execution = await dest.execute({ encodedMessage: '0x...', verifications: [...], - wallet: cantonWallet, + wallet: { party: cantonConfig.party }, receiver: 'receiver::1220...', // optional; defaults to message receiver }) ``` -Canton accepts **ledger update IDs** (`1220` + SHA-256 digest) as transaction identifiers. Use `CantonChain.isTxHash()` to validate update ID format. +Canton accepts **ledger update IDs** (`1220` + SHA-256 digest) as transaction identifiers when the source is Canton. Use `CantonChain.isTxHash()` to validate update ID format. ## Using the CLI @@ -350,12 +356,18 @@ ccip-cli manual-exec 0xSourceTxHash \ --wallet $PRIVATE_KEY \ --log-index 1 -# Canton destination (by ledger update ID) +# Canton source (by ledger update ID; --wallet needed for non-Canton dest) ccip-cli manual-exec 1220a1b2c3d4... \ --canton-config ./canton-config.json \ --rpc https://ledger.example.com/api/json \ - --receiver sender::1220... \ - --wallet <64-char-ed25519-seed> + --rpc https://ethereum-sepolia-rpc.example.com \ + --wallet $PRIVATE_KEY + +# Canton destination (no --wallet needed; JWT from canton-config is used) +ccip-cli manual-exec 0x1234... \ + --canton-config ./canton-config.json \ + --rpc https://ledger.example.com/api/json \ + --rpc https://ethereum-sepolia-rpc.example.com ``` The CLI auto-detects whether the argument is a message ID, transaction hash, or Canton update ID. diff --git a/ccip-api-ref/docs-sdk/guides/multi-chain.mdx b/ccip-api-ref/docs-sdk/guides/multi-chain.mdx index efb2c34d5..4470fddd4 100644 --- a/ccip-api-ref/docs-sdk/guides/multi-chain.mdx +++ b/ccip-api-ref/docs-sdk/guides/multi-chain.mdx @@ -85,6 +85,9 @@ const chain = await CantonChain.fromUrl('https://ledger.example.com/api/json', { cantonConfig: { party: 'sender::1220...', ccipParty: 'ccip::1220...', + // Either a static JWT string, or a () => Promise getter for + // refreshable tokens (OAuth2). The CLI resolves its `auth` block into + // one of these upfront — see Canton Configuration. jwt: 'eyJ...', edsUrl: 'https://eds.example.com', transferInstructionUrl: 'https://transfer-instruction.example.com', @@ -96,7 +99,7 @@ const chain = await CantonChain.fromUrl('https://ledger.example.com/api/json', { console.log('Connected to:', chain.network.name) ``` -Canton requires `cantonConfig` in the second argument to `fromUrl`. See [Canton Configuration](/cli/configuration#canton-configuration) for the full config schema. +Canton requires `cantonConfig` in the second argument to `fromUrl`. The `jwt` field accepts either a string (pre-obtained token) or a `() => Promise` getter (for refreshable OAuth2 tokens). The SDK is runtime-agnostic — it never orchestrates an OAuth flow; the CLI or embedder resolves auth upfront and injects the result. See [Canton Configuration](/cli/configuration#canton-configuration) for the full config schema. @@ -293,7 +296,7 @@ const message = { import { CantonChain, networkInfo } from '@chainlink/ccip-sdk' const source = await CantonChain.fromUrl('https://ledger.example.com/api/json', { - cantonConfig: { /* party, ccipParty, jwt, edsUrl, transferInstructionUrl, ... */ }, + cantonConfig: { /* party, ccipParty, jwt (string or getter), edsUrl, transferInstructionUrl, ... */ }, }) const destSelector = networkInfo('ethereum-testnet-sepolia').chainSelector @@ -407,7 +410,7 @@ const signature = await sendTransaction(transaction, connection) ### Configuration -Canton requires a `CantonConfig` object passed via `ChainContext.cantonConfig` when calling `CantonChain.fromUrl`. Required fields: `party`, `ccipParty`, `jwt`, `edsUrl`, `transferInstructionUrl`. See [Canton Configuration](/cli/configuration#canton-configuration) for the full schema. +Canton requires a `CantonConfig` object passed via `ChainContext.cantonConfig` when calling `CantonChain.fromUrl`. Required fields: `party`, `ccipParty`, `edsUrl`, `transferInstructionUrl` — plus `jwt` (a string for a pre-obtained token, or a `() => Promise` getter for refreshable OAuth2 tokens). The SDK is runtime-agnostic and never orchestrates an OAuth flow — the CLI resolves its `auth` block upfront and injects `jwt`; web/Electron embedders compose the SDK's OAuth2 protocol helpers (`buildAuthorizationRequest`, `validateAuthorizationCallback`, `exchangeAuthorizationCode`) with their own redirect/callback handling. See [Canton Configuration](/cli/configuration#canton-configuration) for the full schema. ### Identity and Addresses diff --git a/ccip-api-ref/docusaurus.config.ts b/ccip-api-ref/docusaurus.config.ts index 837aa7cfd..6ed6fe09c 100644 --- a/ccip-api-ref/docusaurus.config.ts +++ b/ccip-api-ref/docusaurus.config.ts @@ -50,6 +50,10 @@ const config: Config = { // // - `path`: used by bigint-buffer / postman-code-generators (pre-existing) // - `undici`: used by @chainlink/ccip-sdk's Canton client (CantonChain → canton/client.ts). + // + // The Canton auth-code provider's `node:*` imports (callback server, browser + // launching) have been moved to the CLI, so no `node:` scheme stubs are + // needed here anymore — the SDK is now runtime-agnostic. function webpackNodeFallbacks(): Plugin { return { name: 'webpack-node-fallbacks', diff --git a/ccip-api-ref/src/components/composed/RpcProviders/RpcProviders.tsx b/ccip-api-ref/src/components/composed/RpcProviders/RpcProviders.tsx index 35ff4f184..32872c129 100644 --- a/ccip-api-ref/src/components/composed/RpcProviders/RpcProviders.tsx +++ b/ccip-api-ref/src/components/composed/RpcProviders/RpcProviders.tsx @@ -150,8 +150,9 @@ const CHAIN_TIPS: Record = { canton: ( <> Canton uses the Ledger JSON API (not a traditional RPC). Your network operator provides the - ledger URL (typically ending in /api/json). Authentication requires a JWT — see{' '} - Canton Configuration. + ledger URL (typically ending in /api/json). Authentication requires a JWT, either + pre-obtained or resolved automatically via OIDC — see{' '} + Canton Authentication. ), } diff --git a/ccip-cli/README.md b/ccip-cli/README.md index 7fbcc3559..d0844678b 100644 --- a/ccip-cli/README.md +++ b/ccip-cli/README.md @@ -283,7 +283,10 @@ Canton requires a config file with connection parameters via `--canton-config

[!NOTE] +> The top-level `jwt` field is shorthand for `auth: { type: "static", jwt }` — both are equivalent. If both are present, `jwt` takes precedence. + +**Required fields:** `party`, `ccipParty`, `edsUrl`, `transferInstructionUrl` — plus either `jwt` (pre-obtained token) or `auth` (OIDC config, see below) +**Optional fields:** `jwt`, `auth`, `externalEdsUrlsByOwner`, `indexerUrl`, `chainId`, `senderInstanceId`, `defaultSendGasLimit`, `feeTransferFactoryAmount`, `ccvs`, `packages` + +#### Canton authentication + +The `auth` object supports three flows. The `static` flow wraps a pre-obtained JWT (equivalent to the top-level `jwt` field); `clientCredentials` and `authorizationCode` obtain a JWT automatically via [OpenID Connect (OIDC)](https://openid.net/connect/): + +| `auth.type` | Use case | Required fields | +| --------------------- | -------------------------------- | ---------------------------------------- | +| `static` | Pre-obtained JWT | `jwt` | +| `clientCredentials` | Machine-to-machine (CI/CD) | `authUrl`, `clientId`†, `clientSecret`† | +| `authorizationCode` | Interactive browser login (PKCE) | `authUrl`, `clientId`† | + +† `clientId` and `clientSecret` may be omitted from the config file and resolved from `CANTON_CLIENT_ID` / `CANTON_CLIENT_SECRET` env vars. **Keep secrets in env vars, not in config files.** + +```json +{ + "party": "sender::1220...", + "ccipParty": "ccip::1220...", + "auth": { + "type": "clientCredentials", + "authUrl": "https://auth.example.com" + }, + "edsUrl": "https://eds.example.com", + "transferInstructionUrl": "https://transfer-instruction.example.com" +} +``` + +```bash +export CANTON_CLIENT_ID="my-client-id" +export CANTON_CLIENT_SECRET="my-client-secret" +``` + +When `auth` is set, the CLI resolves a JWT upfront (before connecting to the ledger) via the SDK's runtime-agnostic OAuth 2.0 protocol helpers (backed by [`oauth4webapi`](https://github.com/panva/oauth4webapi)). For `clientCredentials` and `authorizationCode`, the CLI injects a `() => Promise` getter as `jwt` so tokens are refreshed automatically per request. If both `jwt` and `auth` are present, `jwt` takes precedence. + +> [!NOTE] +> The `authorizationCode` flow is orchestrated by the CLI: it starts a local callback server (`node:http`) and opens the default browser (`open`/`xdg-open`). These Node-specific steps live in the CLI, not the SDK, so the SDK stays runtime-agnostic (no `node:*` imports) and can be embedded in web/Electron apps. Web embedders compose the SDK's protocol helpers (`buildAuthorizationRequest`, `validateAuthorizationCallback`, `exchangeAuthorizationCode`) with their own redirect/callback handling. #### Example diff --git a/ccip-cli/src/providers/canton.test.ts b/ccip-cli/src/providers/canton.test.ts index b08a0a139..2066a85c4 100644 --- a/ccip-cli/src/providers/canton.test.ts +++ b/ccip-cli/src/providers/canton.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { createHash, createPrivateKey, createPublicKey, verify } from 'node:crypto' import { describe, it } from 'node:test' -import { Ed25519TransactionSigner } from './canton.ts' +import { Ed25519TransactionSigner } from './canton/index.ts' // --------------------------------------------------------------------------- // Test Constants diff --git a/ccip-cli/src/providers/canton/auth.test.ts b/ccip-cli/src/providers/canton/auth.test.ts new file mode 100644 index 000000000..831a93057 --- /dev/null +++ b/ccip-cli/src/providers/canton/auth.test.ts @@ -0,0 +1,317 @@ +/** + * Unit tests for the CLI Canton OAuth 2.0 orchestration (Node-specific). + * + * Tests the callback server + browser launching that was moved out of the SDK + * into the CLI's `providers/canton/auth.ts`. The SDK protocol primitives are + * tested in `ccip-sdk/src/canton/authentication/authentication.test.ts`. + */ +import assert from 'node:assert/strict' +import { type Server, createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import { describe, it } from 'node:test' + +import { CantonAuthType as AuthType } from '@chainlink/ccip-sdk/src/index.ts' + +import { mergeAuthEnvVars, resolveCantonTokenGetter, runAuthorizationCodeFlow } from './auth.ts' + +// --------------------------------------------------------------------------- +// Helpers — mock OAuth2 token + metadata servers +// --------------------------------------------------------------------------- + +/** Start a mock token endpoint that returns a canned token. */ +function startTokenServer(opts: { + response?: Record + status?: number +}): Promise<{ server: Server; url: string }> { + const server = createServer((req, res) => { + const status = opts.status ?? 200 + const json = + opts.response ?? + ({ access_token: 'test-access-token', token_type: 'Bearer', expires_in: 3600 } as const) + res.writeHead(status, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(json)) + }) + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() as AddressInfo + resolve({ server, url: `http://127.0.0.1:${port}` }) + }) + }) +} + +/** Start a mock authorization-server metadata endpoint. */ +function startMetadataServer(opts: { + tokenEndpoint?: string + authorizationEndpoint?: string +}): Promise<{ server: Server; baseUrl: string }> { + const server = createServer((req, res) => { + if (req.url !== '/.well-known/oauth-authorization-server') { + res.writeHead(404) + res.end('not found') + return + } + const baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + const json = { + issuer: baseUrl, + token_endpoint: opts.tokenEndpoint ?? `${baseUrl}/v1/token`, + authorization_endpoint: opts.authorizationEndpoint ?? `${baseUrl}/v1/authorize`, + code_challenge_methods_supported: ['S256'], + } + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(json)) + }) + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() as AddressInfo + resolve({ server, baseUrl: `http://127.0.0.1:${port}` }) + }) + }) +} + +/** Grab a free TCP port then release it immediately. */ +function grabFreePort(): Promise { + const grabber = createServer() + return new Promise((resolve) => { + grabber.listen(0, '127.0.0.1', () => { + const { port } = grabber.address() as AddressInfo + grabber.close(() => resolve(port)) + }) + }) +} + +/** Poll until a TCP port accepts a connection (callback server is up). */ +function waitForPort(port: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + return new Promise((resolve, reject) => { + function attempt() { + fetch(`http://127.0.0.1:${port}/__nonexistent__`) + .then(() => resolve()) + .catch(() => { + if (Date.now() > deadline) reject(new Error(`port ${port} not ready in ${timeoutMs}ms`)) + else setTimeout(attempt, 20) + }) + } + attempt() + }) +} + +// --------------------------------------------------------------------------- +// mergeAuthEnvVars +// --------------------------------------------------------------------------- + +describe('cli/providers/canton/auth — mergeAuthEnvVars', () => { + it('fills clientId/clientSecret from env for clientCredentials', () => { + const prevId = process.env.CANTON_CLIENT_ID + const prevSecret = process.env.CANTON_CLIENT_SECRET + process.env.CANTON_CLIENT_ID = 'env-cid' + process.env.CANTON_CLIENT_SECRET = 'env-secret' + try { + const merged = mergeAuthEnvVars({ + type: AuthType.ClientCredentials, + authUrl: 'https://auth.example.com', + clientId: '', + clientSecret: '', + }) + assert.equal(merged.type, AuthType.ClientCredentials) + assert.equal((merged as { clientId: string }).clientId, 'env-cid') + assert.equal((merged as { clientSecret: string }).clientSecret, 'env-secret') + } finally { + process.env.CANTON_CLIENT_ID = prevId + process.env.CANTON_CLIENT_SECRET = prevSecret + } + }) + + it('fills clientId from env for authorizationCode', () => { + const prevId = process.env.CANTON_CLIENT_ID + process.env.CANTON_CLIENT_ID = 'env-cid' + try { + const merged = mergeAuthEnvVars({ + type: AuthType.AuthorizationCode, + authUrl: 'https://auth.example.com', + clientId: '', + }) + assert.equal(merged.type, AuthType.AuthorizationCode) + assert.equal((merged as { clientId: string }).clientId, 'env-cid') + } finally { + process.env.CANTON_CLIENT_ID = prevId + } + }) + + it('preserves explicit config values over env', () => { + const prevId = process.env.CANTON_CLIENT_ID + process.env.CANTON_CLIENT_ID = 'env-cid' + try { + const merged = mergeAuthEnvVars({ + type: AuthType.AuthorizationCode, + authUrl: 'https://auth.example.com', + clientId: 'explicit-cid', + }) + assert.equal((merged as { clientId: string }).clientId, 'explicit-cid') + } finally { + process.env.CANTON_CLIENT_ID = prevId + } + }) + + it('passes static config through unchanged', () => { + const merged = mergeAuthEnvVars({ jwt: 'my-jwt' }) + assert.equal(merged.type ?? 'static', 'static') + }) + + it('throws a helpful error mentioning env vars when clientCredentials credentials are missing', () => { + const prevId = process.env.CANTON_CLIENT_ID + const prevSecret = process.env.CANTON_CLIENT_SECRET + delete process.env.CANTON_CLIENT_ID + delete process.env.CANTON_CLIENT_SECRET + try { + assert.throws( + () => + mergeAuthEnvVars({ + type: AuthType.ClientCredentials, + authUrl: 'https://auth.example.com', + clientId: '', + clientSecret: '', + }), + /CANTON_CLIENT_ID.*CANTON_CLIENT_SECRET/, + ) + } finally { + process.env.CANTON_CLIENT_ID = prevId + process.env.CANTON_CLIENT_SECRET = prevSecret + } + }) + + it('throws a helpful error mentioning env vars when authorizationCode clientId is missing', () => { + const prevId = process.env.CANTON_CLIENT_ID + delete process.env.CANTON_CLIENT_ID + try { + assert.throws( + () => + mergeAuthEnvVars({ + type: AuthType.AuthorizationCode, + authUrl: 'https://auth.example.com', + clientId: '', + }), + /CANTON_CLIENT_ID/, + ) + } finally { + process.env.CANTON_CLIENT_ID = prevId + } + }) +}) + +// --------------------------------------------------------------------------- +// runAuthorizationCodeFlow (callback server + browser orchestration) +// --------------------------------------------------------------------------- + +describe('cli/providers/canton/auth — runAuthorizationCodeFlow', () => { + it('completes the PKCE flow when the callback is hit with matching state', async () => { + const tokenServer = await startTokenServer({ + response: { access_token: 'auth-code-token', token_type: 'Bearer', expires_in: 3600 }, + }) + const metaServer = await startMetadataServer({ + tokenEndpoint: `${tokenServer.url}/v1/token`, + authorizationEndpoint: `${tokenServer.url}/v1/authorize`, + }) + const callbackPort = await grabFreePort() + const callbackUrl = `http://127.0.0.1:${callbackPort}/callback` + const knownState = 'known-test-state' + + try { + // Start the flow in the background with a fixed state for deterministic testing. + const flowPromise = runAuthorizationCodeFlow( + { + type: AuthType.AuthorizationCode, + authUrl: metaServer.baseUrl, + clientId: 'cid', + callbackUrl, + }, + { + openBrowser: false, + timeoutMs: 5_000, + stateOverride: knownState, + allowInsecureRequests: true, + }, + ) + + // Wait for the callback server to start, then simulate the browser redirect. + await waitForPort(callbackPort, 1_000) + const resp = await fetch(`${callbackUrl}?code=test-code&state=${knownState}`) + assert.equal(resp.status, 200) + + const token = await flowPromise + assert.equal(token.accessToken, 'auth-code-token') + } finally { + tokenServer.server.close() + metaServer.server.close() + } + }) + + it('rejects on state mismatch', async () => { + const tokenServer = await startTokenServer({}) + const metaServer = await startMetadataServer({ + tokenEndpoint: `${tokenServer.url}/v1/token`, + authorizationEndpoint: `${tokenServer.url}/v1/authorize`, + }) + const callbackPort = await grabFreePort() + const callbackUrl = `http://127.0.0.1:${callbackPort}/callback` + + try { + const flowPromise = runAuthorizationCodeFlow( + { + type: AuthType.AuthorizationCode, + authUrl: metaServer.baseUrl, + clientId: 'cid', + callbackUrl, + }, + { + openBrowser: false, + timeoutMs: 5_000, + stateOverride: 'correct-state', + allowInsecureRequests: true, + }, + ) + flowPromise.catch(() => {}) + + await waitForPort(callbackPort, 1_000) + // Hit with a wrong state → flow should reject. + const resp = await fetch(`${callbackUrl}?code=x&state=wrong`) + assert.equal(resp.status, 400) + await assert.rejects(flowPromise, Error) + } finally { + tokenServer.server.close() + metaServer.server.close() + } + }) +}) + +// --------------------------------------------------------------------------- +// resolveCantonTokenGetter +// --------------------------------------------------------------------------- + +describe('cli/providers/canton/auth — resolveCantonTokenGetter', () => { + it('returns a static jwt string for static auth', async () => { + const jwt = await resolveCantonTokenGetter({ jwt: 'static-jwt-123' }) + assert.equal(jwt, 'static-jwt-123') + }) + + it('returns a token getter function for clientCredentials', async () => { + const tokenServer = await startTokenServer({}) + const metaServer = await startMetadataServer({ tokenEndpoint: `${tokenServer.url}/v1/token` }) + try { + const jwt = await resolveCantonTokenGetter( + { + type: AuthType.ClientCredentials, + authUrl: metaServer.baseUrl, + clientId: 'cid', + clientSecret: 'secret', + }, + { allowInsecureRequests: true }, + ) + assert.ok(typeof jwt === 'function') + const token = await (jwt as () => Promise)() + assert.equal(token, 'test-access-token') + } finally { + tokenServer.server.close() + metaServer.server.close() + } + }) +}) diff --git a/ccip-cli/src/providers/canton/auth.ts b/ccip-cli/src/providers/canton/auth.ts new file mode 100644 index 000000000..f140f3c47 --- /dev/null +++ b/ccip-cli/src/providers/canton/auth.ts @@ -0,0 +1,472 @@ +import { execFile } from 'node:child_process' +import { type Server, createServer } from 'node:http' + +import { + type AccessToken, + type AuthorizationCodeAuthConfig, + type CantonAuthConfig, + type CantonAuthProvider, + type CantonAuthProviderOptions, + CCIPError, + CCIPErrorCode, + CantonAuthType, + CantonStaticProvider, + buildCantonAuthorizationRequest, + createCantonAuthProvider, + createCantonMemoizedTokenFetcher, + exchangeCantonAuthorizationCode, + refreshCantonAuthorizationCodeToken, + validateCantonAuthorizationCallback, +} from '@chainlink/ccip-sdk/src/index.ts' + +/** + * Canton OAuth 2.0 orchestration — Node-specific bits that compose the SDK's + * runtime-agnostic protocol helpers. + * + * @packageDocumentation + * + * The SDK exports only the protocol pieces (build-authorize-URL, callback + * validation, code→token exchange, refresh grant, client-credentials/static + * providers). This module owns everything environment-specific: + * - the local `node:http` callback server for the authorization-code flow + * - `open`/`xdg-open` browser launching + * - `CANTON_CLIENT_ID` / `CANTON_CLIENT_SECRET` env-var resolution + * - timeouts and terminal UX + * + * It resolves auth upfront and hands the result to `cantonConfig` as either a + * static `jwt` string or a `() => Promise` token getter that the SDK + * clients call per request (enabling automatic refresh). + */ + +/** Default local redirect URI for the authorization-code callback server. */ +const DEFAULT_CALLBACK_URL = 'http://localhost:8400/callback' + +/** Default overall authorization-code flow timeout (2 minutes). */ +const DEFAULT_FLOW_TIMEOUT_MS = 120_000 + +/** HTML shown to the user in the browser after a successful callback. */ +const CALLBACK_SUCCESS_HTML = ` + +Authentication Complete + +

Authentication complete!

+

You can safely close this window.

+ +` + +/** + * Merge `CANTON_CLIENT_ID` / `CANTON_CLIENT_SECRET` env vars into an + * {@link AuthConfig} when the `auth` block omits them. + * + * This allows config files to specify the non-secret OIDC parameters (`type`, + * `authUrl`, `audience`, `scopes`) while credentials come from environment + * variables — keeping secrets out of version-controlled JSON files. + */ +export function mergeAuthEnvVars(auth: CantonAuthConfig): CantonAuthConfig { + const envClientId = process.env.CANTON_CLIENT_ID?.trim() + const envClientSecret = process.env.CANTON_CLIENT_SECRET?.trim() + + if (auth.type === CantonAuthType.ClientCredentials) { + const clientId = auth.clientId || envClientId || '' + const clientSecret = auth.clientSecret || envClientSecret || '' + if (!clientId || !clientSecret) { + throw new CCIPError( + CCIPErrorCode.CANTON_AUTH_ERROR, + 'clientCredentials auth requires a clientId and clientSecret. Set them via the CANTON_CLIENT_ID and CANTON_CLIENT_SECRET environment variables.', + ) + } + return { ...auth, clientId, clientSecret } + } + + if (auth.type === CantonAuthType.AuthorizationCode) { + const clientId = auth.clientId || envClientId || '' + if (!clientId) { + throw new CCIPError( + CCIPErrorCode.CANTON_AUTH_ERROR, + 'authorizationCode auth requires a clientId. Set it via the CANTON_CLIENT_ID environment variable.', + ) + } + return { ...auth, clientId } + } + + return auth +} + +/** + * Open a URL in the default browser (cross-platform best-effort). + * + * Uses `execFile` (not `exec`) to avoid spawning a shell, preventing command + * injection through the URL string. + */ +export function openBrowser(url: string): Promise { + const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open' + return new Promise((resolve) => { + execFile(cmd, [url], (err) => { + if (err) { + process.stderr.write(`Could not open browser — visit this URL manually:\n${url}\n`) + } + resolve() + }) + }) +} + +/** + * Run the full authorization-code + PKCE flow on Node: start a local callback + * server, open the browser, wait for the callback, and exchange the code for + * tokens. + * + * Composes the SDK protocol helpers ({@link buildAuthorizationRequest}, + * {@link validateAuthorizationCallback}, {@link exchangeAuthorizationCode}) + * with the Node-only callback server and browser launching. + * + * @param config - Authorization code config (with `authUrl`, `clientId`). + * @param options - Optional fetch override, abort signal, and flow controls + * (`callbackUrl`, `openBrowser`, `timeoutMs`). + * @returns The obtained {@link AccessToken}. + * @throws {@link CCIPError} (CANTON_AUTH_ERROR) on timeout, callback errors, + * state mismatch, or token exchange failure. + */ +export async function runAuthorizationCodeFlow( + config: AuthorizationCodeAuthConfig, + options?: CantonAuthProviderOptions & { + /** Local redirect URI. Defaults to `http://localhost:8400/callback`. */ + callbackUrl?: string + /** Open the browser automatically (default `true`). */ + openBrowser?: boolean + /** Overall flow timeout in ms (default 120_000). */ + timeoutMs?: number + /** Override the PKCE state parameter (for deterministic testing). */ + stateOverride?: string + /** Override the PKCE code verifier (for deterministic testing). */ + verifierOverride?: string + }, +): Promise { + const callbackUrl = options?.callbackUrl ?? config.callbackUrl ?? DEFAULT_CALLBACK_URL + const shouldOpenBrowser = options?.openBrowser ?? true + const timeoutMs = options?.timeoutMs ?? DEFAULT_FLOW_TIMEOUT_MS + + const req = await buildCantonAuthorizationRequest(config, { + fetch: options?.fetch, + signal: options?.signal, + allowInsecureRequests: options?.allowInsecureRequests, + redirectUri: callbackUrl, + stateOverride: options?.stateOverride, + verifierOverride: options?.verifierOverride, + }) + + const callback = new URL(callbackUrl) + const callbackPath = callback.pathname || '/callback' + const callbackHost = callback.hostname || '127.0.0.1' + const callbackPort = Number(callback.port) || 8400 + + return new Promise((resolve, reject) => { + let settled = false + + const finish = (fn: () => void) => { + if (settled) return + settled = true + clearTimeout(timer) + server.close() + fn() + } + + const server: Server = createServer((req2, res) => { + const reqUrl = new URL(req2.url ?? '/', `http://${callbackHost}:${callbackPort}`) + if (reqUrl.pathname !== callbackPath) { + res.writeHead(404, { 'Content-Type': 'text/plain' }) + res.end('Not found') + return + } + + validateCantonAuthorizationCallback(config, reqUrl, req.state, { + fetch: options?.fetch, + signal: options?.signal, + allowInsecureRequests: options?.allowInsecureRequests, + }) + .then((callback) => + exchangeCantonAuthorizationCode(config, callback, req.verifier, req.redirectUri, { + fetch: options?.fetch, + signal: options?.signal, + allowInsecureRequests: options?.allowInsecureRequests, + }), + ) + .then((token: AccessToken) => { + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + res.end(CALLBACK_SUCCESS_HTML) + finish(() => resolve(token)) + }) + .catch((e: unknown) => { + const message = e instanceof Error ? e.message : String(e) + try { + res.writeHead(400, { 'Content-Type': 'text/plain' }) + // Send a generic message to the browser — never expose internal + // error details or stack traces in the HTTP response body. + res.end('Authentication failed. Check the terminal for details.') + } catch { + // response may already be sent + } + finish(() => + reject( + e instanceof CCIPError + ? e + : new CCIPError(CCIPErrorCode.CANTON_AUTH_ERROR, message, { + cause: e instanceof Error ? e : undefined, + }), + ), + ) + }) + }) + + const timer = setTimeout(() => { + if (settled) return + settled = true + server.close() + reject( + new CCIPError( + CCIPErrorCode.CANTON_AUTH_ERROR, + `Authorization code flow timed out after ${timeoutMs}ms`, + { isTransient: true }, + ), + ) + }, timeoutMs) + + server.on('error', (err) => { + finish(() => + reject( + new CCIPError(CCIPErrorCode.CANTON_AUTH_ERROR, `Callback server error: ${err.message}`, { + cause: err, + }), + ), + ) + }) + + server.listen(callbackPort, callbackHost, () => { + process.stderr.write(`Waiting for authentication on ${callbackUrl}\n`) + if (shouldOpenBrowser) { + process.stderr.write('Opening browser for login…\n') + process.stderr.write(`If the browser does not open, visit:\n${req.authorizeUrl}\n\n`) + void openBrowser(req.authorizeUrl) + } else { + process.stderr.write(`Visit the following URL to authenticate:\n${req.authorizeUrl}\n\n`) + } + }) + }) +} + +/** + * Build an {@link AuthProvider} for the CLI from a discriminated + * {@link AuthConfig}, resolving `CANTON_CLIENT_ID` / `CANTON_CLIENT_SECRET` + * from env vars and orchestrating the authorization-code flow on Node. + * + * - `static` / `clientCredentials`: delegated to the SDK's + * {@link createAuthProvider} (runtime-agnostic). + * - `authorizationCode`: orchestrated here via {@link runAuthorizationCodeFlow} + * and wrapped in a caching provider that refreshes via the SDK's + * `refreshAuthorizationCodeToken` helper. + * + * @param auth - Auth config (static, clientCredentials, or authorizationCode). + * @param options - Optional fetch override, abort signal, and flow controls. + * @returns An {@link AuthProvider} whose `token()` yields valid JWTs. + * @throws {@link CCIPError} (CANTON_AUTH_ERROR) on invalid config or auth failure. + */ +export async function createCliAuthProvider( + auth: CantonAuthConfig, + options?: CantonAuthProviderOptions & { + callbackUrl?: string + openBrowser?: boolean + timeoutMs?: number + }, +): Promise { + return getOrCreateCliAuthProvider(auth, options) +} + +/** + * CLI authorization-code provider: wraps the SDK's caching plumbing with the + * Node-specific re-fetch callback (re-runs the interactive flow). + * + * Refresh delegates to the SDK's `refreshAuthorizationCodeToken` helper; when + * that fails (or no refresh token is held), the interactive flow is re-run. + */ +class CliAuthorizationCodeProvider implements CantonAuthProvider { + readonly type = 'authorizationCode' as const + private readonly fetchToken: () => Promise + private readonly cfg: AuthorizationCodeAuthConfig + private readonly options: CantonAuthProviderOptions | undefined + private readonly reFetchToken: () => Promise + /** The last-seen token; updated on each fetch/refresh so doRefresh can read its refreshToken. */ + private lastToken: AccessToken | undefined + + constructor( + cfg: AuthorizationCodeAuthConfig, + initialToken: AccessToken, + fetchToken: () => Promise, + options?: CantonAuthProviderOptions, + ) { + this.cfg = cfg + this.options = options + this.reFetchToken = fetchToken + this.lastToken = initialToken + this.fetchToken = createCantonMemoizedTokenFetcher(() => this.doRefresh(), initialToken) + } + + token(): Promise { + return this.fetchToken() + } + + private async doRefresh(): Promise { + if (this.lastToken?.refreshToken) { + try { + const refreshed = await refreshCantonAuthorizationCodeToken( + this.cfg, + this.lastToken.refreshToken, + this.options, + ) + this.lastToken = refreshed + return refreshed + } catch { + // refresh failed — fall through to re-running the interactive flow + } + } + const fetched = await this.reFetchToken() + this.lastToken = fetched + return fetched + } +} + +/** + * Resolve a JWT (or token getter) for a {@link CantonConfig} from an + * {@link AuthConfig}, suitable for `cantonConfig.jwt`. + * + * The CLI calls this upfront (before `CantonChain.fromUrl`) so auth is resolved + * once. When the auth config is `static`, a static JWT string is returned; for + * refreshable flows (clientCredentials / authorizationCode), a + * `() => Promise` getter is returned so the SDK clients refresh per + * request. + * + * The resolved provider is memoized process-wide by auth-config fingerprint, so + * repeated calls within one process (e.g. `send` → `showRequests` both calling + * `fetchChainsFromRpcs`) reuse the same cached token instead of re-running the + * interactive flow. + * + * @param auth - Auth config (static, clientCredentials, or authorizationCode). + * @param options - Optional fetch override, abort signal, and flow controls. + * @returns A `string` for static auth, or a `() => Promise` getter for + * refreshable flows (clientCredentials / authorizationCode). + * @throws {@link CCIPError} (CANTON_AUTH_ERROR) on invalid config or auth failure. + * + * @example + * ```ts + * const jwt = await resolveCantonTokenGetter(auth) + * const cantonConfig = { ...rest, jwt } + * ``` + */ +export async function resolveCantonTokenGetter( + auth: CantonAuthConfig, + options?: CantonAuthProviderOptions & { + callbackUrl?: string + openBrowser?: boolean + timeoutMs?: number + }, +): Promise Promise)> { + const provider = await getOrCreateCliAuthProvider(auth, options) + + // Static tokens never expire → return a plain jwt string. + if (provider instanceof CantonStaticProvider) { + const token = await provider.token() + return token.accessToken + } + + // Refreshable flows (clientCredentials / authorizationCode) → return a + // getter the SDK clients call per request. + const tokenGetter = async () => { + const token = await provider.token() + return token.accessToken + } + // Eagerly fetch the first token so connection-time errors surface early. + await tokenGetter() + return tokenGetter +} + +/** + * Process-wide cache of CLI auth providers, keyed by config fingerprint. + * + * This prevents re-running the interactive authorization-code flow when + * multiple CLI code paths call `fetchChainsFromRpcs` in one process (e.g. + * `send` resolves auth, then calls `showRequests` which resolves auth again). + * The cached provider holds a `CachingTokenSource` so subsequent `token()` + * calls reuse the already-obtained token until it expires. + */ +const cliAuthProviderCache = new Map() + +/** + * Stable fingerprint for a {@link CantonAuthConfig}, ignoring volatile fields. + * + * Two configs with the same `type`/`authUrl`/`audience`/`scopes`/`clientId` + * resolve to the same provider — the `clientSecret` is intentionally included + * so different secrets yield different providers (and the env-var merge happens + * before fingerprinting). + */ +function cliAuthFingerprint(auth: CantonAuthConfig): string { + const parts: string[] = [auth.type ?? CantonAuthType.Static] + if ('authUrl' in auth && typeof auth.authUrl === 'string') parts.push(`authUrl=${auth.authUrl}`) + if ('audience' in auth && typeof auth.audience === 'string') + parts.push(`audience=${auth.audience}`) + if ('scopes' in auth && Array.isArray(auth.scopes)) parts.push(`scopes=${auth.scopes.join(',')}`) + if ('clientId' in auth && typeof auth.clientId === 'string') + parts.push(`clientId=${auth.clientId}`) + if ('clientSecret' in auth && typeof auth.clientSecret === 'string') + parts.push(`clientSecret=${auth.clientSecret}`) + return parts.join('|') +} + +/** + * Return a cached {@link CantonAuthProvider} for `auth`, or create and cache one. + * + * The env-var merge (`mergeAuthEnvVars`) runs before fingerprinting so that + * `CANTON_CLIENT_ID` / `CANTON_CLIENT_SECRET` are part of the key. + */ +async function getOrCreateCliAuthProvider( + auth: CantonAuthConfig, + options?: CantonAuthProviderOptions & { + callbackUrl?: string + openBrowser?: boolean + timeoutMs?: number + }, +): Promise { + const merged = mergeAuthEnvVars(auth) + const key = cliAuthFingerprint(merged) + let provider = cliAuthProviderCache.get(key) + if (!provider) { + provider = await createCliAuthProviderInternal(merged, options) + cliAuthProviderCache.set(key, provider) + } + return provider +} + +/** + * Internal provider creation (no caching) — called by {@link getOrCreateCliAuthProvider}. + * + * `createCliAuthProvider` (public) delegates here after merging env vars; the + * caching layer wraps this so the interactive flow runs at most once per + * fingerprint per process. + */ +async function createCliAuthProviderInternal( + merged: CantonAuthConfig, + options?: CantonAuthProviderOptions & { + callbackUrl?: string + openBrowser?: boolean + timeoutMs?: number + }, +): Promise { + const type = merged.type ?? CantonAuthType.Static + + if (type === CantonAuthType.AuthorizationCode) { + const cfg = merged as AuthorizationCodeAuthConfig + const initialToken = await runAuthorizationCodeFlow(cfg, options) + // The re-fetch callback re-runs the interactive flow when refresh fails. + const fetchToken = () => runAuthorizationCodeFlow(cfg, options) + return new CliAuthorizationCodeProvider(cfg, initialToken, fetchToken, options) + } + + return createCantonAuthProvider(merged, options) +} diff --git a/ccip-cli/src/providers/canton/config.ts b/ccip-cli/src/providers/canton/config.ts new file mode 100644 index 000000000..ff69f2c46 --- /dev/null +++ b/ccip-cli/src/providers/canton/config.ts @@ -0,0 +1,97 @@ +import { existsSync, readFileSync } from 'node:fs' + +import type { CantonAuthConfig, CantonConfig, Logger } from '@chainlink/ccip-sdk/src/index.ts' + +/** + * A Canton config as loaded from the CLI's JSON file, before the `auth` block + * is resolved into a `jwt` (string or getter). + * + * The file may carry either a static `jwt` string or an `auth` block (OIDC: + * `static` / `clientCredentials` / `authorizationCode`). The CLI resolves + * `auth` upfront (see {@link resolveCantonTokenGetter}) and hands the SDK a + * config with only `jwt` — the SDK never sees `auth`. + */ +export type CantonCliConfig = CantonConfig & { auth?: CantonAuthConfig } + +/** + * Load and validate a Canton config JSON file. + * + * The config may carry either: + * - a static `jwt` string, or + * - an `auth` block (OIDC: `static` / `clientCredentials` / `authorizationCode`), + * which the CLI resolves upfront into a `jwt` (string or getter) before handing + * the config to the SDK. + * + * `jwt` (or `auth`) is required: one of them must be present. + * + * @param configPath - Path to JSON file, or undefined if not provided. + * @param logger - Logger for debug output. + * @returns Parsed {@link CantonCliConfig} (with `auth` preserved) or undefined. + */ +export function loadCantonConfig( + configPath: string | undefined, + logger?: Logger, +): CantonCliConfig | undefined { + if (!configPath) return undefined + if (!existsSync(configPath)) { + throw new Error(`Canton config file not found: ${configPath}`) + } + const raw = readFileSync(configPath, 'utf8') + const parsed = JSON.parse(raw) as Record + + // `jwt` is required unless `auth` is present (OAuth2 provider resolves JWT on demand). + const hasAuth = typeof parsed['auth'] === 'object' && parsed['auth'] !== null + const required = hasAuth + ? (['party', 'ccipParty', 'edsUrl', 'transferInstructionUrl'] as const) + : (['party', 'ccipParty', 'jwt', 'edsUrl', 'transferInstructionUrl'] as const) + for (const field of required) { + if (typeof parsed[field] !== 'string' || !parsed[field].length) { + throw new Error(`Canton config: "${field}" is required and must be a non-empty string`) + } + } + + if (parsed['chainId'] != null) { + if (typeof parsed['chainId'] !== 'string' || !parsed['chainId'].length) { + throw new Error('Canton config: "chainId" must be a non-empty string if provided') + } + } + + logger?.debug('Loaded Canton config from', configPath, 'for party', parsed['party']) + return parsed as unknown as CantonCliConfig +} + +/** + * CCIP v2 indexer URLs for verification lookups. + * CLI `--indexer` wins when provided; otherwise uses canton-config `indexerUrl` + * only when the lane involves Canton (EVM-only lanes keep default indexer behavior). + * Prefer {@link resolveIndexer} from `./index.ts` in CLI commands. + */ +export function resolveCliIndexer( + cliIndexer: readonly string[] | undefined, + cantonConfig: Partial | undefined, + laneInvolvesCanton: boolean, +): readonly string[] | undefined { + if (cliIndexer?.length) return cliIndexer + if (!laneInvolvesCanton) return undefined + const url = cantonConfig?.indexerUrl?.trim() + return url ? [url] : undefined +} + +/** + * Router / sender instance id for `ccip-cli send -r`. + * On Canton source lanes this is the CCIPSender instance id (e.g. `prod-ccipsender`); + * on EVM it must be the router contract address. CLI `-r` wins when set. + * Prefer {@link resolveRouter} from `./index.ts` in CLI commands. + */ +export function resolveCliRouter( + cliRouter: string | undefined, + cantonConfig: Partial | undefined, + sourceIsCanton: boolean, +): string | undefined { + if (cliRouter?.trim()) return cliRouter.trim() + if (sourceIsCanton) { + const fromConfig = cantonConfig?.senderInstanceId?.trim() + if (fromConfig) return fromConfig + } + return cliRouter +} diff --git a/ccip-cli/src/providers/canton/index.ts b/ccip-cli/src/providers/canton/index.ts new file mode 100644 index 000000000..00f756f2e --- /dev/null +++ b/ccip-cli/src/providers/canton/index.ts @@ -0,0 +1,35 @@ +/** + * Canton CLI providers — config, wallet, and OAuth 2.0 orchestration. + * + * @packageDocumentation + * + * This folder owns the Node-specific bits of the Canton integration: + * - `config.ts` — loading + validating the Canton config JSON file, and + * CLI/indexer/router resolution helpers. + * - `wallet.ts` — the Ed25519 transaction signer and wallet loading. + * - `auth.ts` — the OAuth 2.0 orchestration (local callback server, browser + * launching, env-var resolution) that composes the SDK's runtime-agnostic + * protocol helpers and hands the result to `cantonConfig`. + * + * The SDK (`@chainlink/ccip-sdk`) is runtime-agnostic: it consumes only what + * it's given (`jwt`) and never orchestrates an OAuth flow. + */ + +export { + type CantonCliConfig, + loadCantonConfig, + resolveCliIndexer, + resolveCliRouter, +} from './config.ts' +export { + createCliAuthProvider, + mergeAuthEnvVars, + openBrowser, + resolveCantonTokenGetter, + runAuthorizationCodeFlow, +} from './auth.ts' +export { + type CantonWalletWithSigner, + Ed25519TransactionSigner, + loadCantonWallet, +} from './wallet.ts' diff --git a/ccip-cli/src/providers/canton.ts b/ccip-cli/src/providers/canton/wallet.ts similarity index 56% rename from ccip-cli/src/providers/canton.ts rename to ccip-cli/src/providers/canton/wallet.ts index 33facb9b3..71a888153 100644 --- a/ccip-cli/src/providers/canton.ts +++ b/ccip-cli/src/providers/canton/wallet.ts @@ -1,18 +1,15 @@ import { createHash, createPrivateKey, createPublicKey, sign } from 'node:crypto' -import { existsSync, readFileSync } from 'node:fs' -import type { - CantonConfig, - Logger, - PartySignatures, - TransactionSigner, -} from '@chainlink/ccip-sdk/src/index.ts' +import type { Logger, PartySignatures, TransactionSigner } from '@chainlink/ccip-sdk/src/index.ts' + +import { loadCantonConfig } from './config.ts' /** * Wallet object returned by {@link loadCantonWallet}. * - * `signer` is present only when the caller supplied a private key, enabling the - * external-signing (prepare → sign → execute) flow. + * `signer` is reserved for the external-signing (prepare → sign → execute) + * flow, which is not yet enabled in `loadCantonWallet`. Canton sends currently + * use JWT-authenticated direct submit. */ export interface CantonWalletWithSigner { party: string @@ -65,10 +62,6 @@ export class Ed25519TransactionSigner implements TransactionSigner { }) // Derive the public key and compute the Canton fingerprint. - // @types/node@26 dropped the KeyObject overload from createPublicKey's - // signature, even though Node itself derives the public key fine from a - // private KeyObject (verified at runtime). Cast through Parameters<> until - // upstream restores the overload. const publicKeyObject = createPublicKey(this.privateKeyObject) const publicKeyDer = publicKeyObject.export({ type: 'spki', format: 'der' }) as Buffer // Ed25519 SPKI DER is 44 bytes: 12-byte header + 32-byte key. @@ -157,96 +150,23 @@ function buildEd25519Pkcs8Der(seed: Buffer): Buffer { // // oxfmt-ignore const prefix = Buffer.from([ - 0x30, 0x2e, // SEQUENCE, 46 bytes - 0x02, 0x01, 0x00, // INTEGER 0 (version) - 0x30, 0x05, // SEQUENCE, 5 bytes (AlgorithmIdentifier) - 0x06, 0x03, // OID, 3 bytes - 0x2b, 0x65, 0x70, // 1.3.101.112 (Ed25519) - 0x04, 0x22, // OCTET STRING, 34 bytes - 0x04, 0x20, // OCTET STRING, 32 bytes (the seed) + 0x30, 0x2e, // SEQUENCE, 46 bytes + 0x02, 0x01, 0x00, // INTEGER 0 (version) + 0x30, 0x05, // SEQUENCE, 5 bytes (AlgorithmIdentifier) + 0x06, 0x03, // OID, 3 bytes + 0x2b, 0x65, 0x70, // 1.3.101.112 (Ed25519) + 0x04, 0x22, // OCTET STRING, 34 bytes + 0x04, 0x20, // OCTET STRING, 32 bytes (the seed) ]) return Buffer.concat([prefix, seed]) } -/** - * Load and validate a Canton config JSON file. - * - * @param configPath - Path to JSON file, or undefined if not provided. - * @param logger - Logger for debug output. - * @returns Parsed CantonConfig or undefined. - */ -export function loadCantonConfig( - configPath: string | undefined, - logger?: Logger, -): CantonConfig | undefined { - if (!configPath) return undefined - if (!existsSync(configPath)) { - throw new Error(`Canton config file not found: ${configPath}`) - } - const raw = readFileSync(configPath, 'utf8') - const parsed = JSON.parse(raw) as Record - - const required = ['party', 'ccipParty', 'jwt', 'edsUrl', 'transferInstructionUrl'] as const - for (const field of required) { - if (typeof parsed[field] !== 'string' || !parsed[field].length) { - throw new Error(`Canton config: "${field}" is required and must be a non-empty string`) - } - } - - if (parsed['chainId'] != null) { - if (typeof parsed['chainId'] !== 'string' || !parsed['chainId'].length) { - throw new Error('Canton config: "chainId" must be a non-empty string if provided') - } - } - - logger?.debug('Loaded Canton config from', configPath, 'for party', parsed['party']) - return parsed as unknown as CantonConfig -} - -/** - * CCIP v2 indexer URLs for verification lookups. - * CLI `--indexer` wins when provided; otherwise uses canton-config `indexerUrl` - * only when the lane involves Canton (EVM-only lanes keep default indexer behavior). - * Prefer {@link resolveIndexer} from `./index.ts` in CLI commands. - */ -export function resolveCliIndexer( - cliIndexer: readonly string[] | undefined, - cantonConfig: Partial | undefined, - laneInvolvesCanton: boolean, -): readonly string[] | undefined { - if (cliIndexer?.length) return cliIndexer - if (!laneInvolvesCanton) return undefined - const url = cantonConfig?.indexerUrl?.trim() - return url ? [url] : undefined -} - -/** - * Router / sender instance id for `ccip-cli send -r`. - * On Canton source lanes this is the CCIPSender instance id (e.g. `prod-ccipsender`); - * on EVM it must be the router contract address. CLI `-r` wins when set. - * Prefer {@link resolveRouter} from `./index.ts` in CLI commands. - */ -export function resolveCliRouter( - cliRouter: string | undefined, - cantonConfig: Partial | undefined, - sourceIsCanton: boolean, -): string | undefined { - if (cliRouter?.trim()) return cliRouter.trim() - if (sourceIsCanton) { - const fromConfig = cantonConfig?.senderInstanceId?.trim() - if (fromConfig) return fromConfig - } - return cliRouter -} - /** * Resolve a Canton wallet from CLI argv. * - * The `party` is sourced from the Canton config file. When a private key is - * provided (via `--wallet`, `PRIVATE_KEY` env, or rpcsFile — resolved upstream - * by `loadChainWallet`), an {@link Ed25519TransactionSigner} is attached so - * `sendMessage` / `execute` use the interactive submission API - * (prepare → sign → execute). + * The `party` is sourced from the Canton config file. Canton sends use + * JWT-authenticated direct submit (no external signer); the `--wallet` flag + * is accepted but ignored on Canton lanes. */ export function loadCantonWallet( argv: { wallet?: unknown; cantonConfig?: string }, @@ -260,14 +180,5 @@ export function loadCantonWallet( ) } - // Disable external signing for now - // - // const privateKey = typeof argv.wallet === 'string' ? argv.wallet : undefined - // if (privateKey && /^(0x)?[0-9a-fA-F]{64}$/.test(privateKey)) { - // const signer = new Ed25519TransactionSigner(privateKey, party) - // logger?.debug(`Canton wallet: external signer created (fingerprint=${signer.getFingerprint()})`) - // return { party, signer } - // } - return { party } } diff --git a/ccip-cli/src/providers/index.test.ts b/ccip-cli/src/providers/index.test.ts index 8abd2733f..7391600ce 100644 --- a/ccip-cli/src/providers/index.test.ts +++ b/ccip-cli/src/providers/index.test.ts @@ -11,7 +11,7 @@ import { } from '@chainlink/ccip-sdk/src/index.ts' import type { Ctx } from '../commands/index.ts' -import { resolveCliIndexer, resolveCliRouter } from './canton.ts' +import { resolveCliIndexer, resolveCliRouter } from './canton/index.ts' import { fetchChainsFromRpcs, filterEndpointsForFamily, diff --git a/ccip-cli/src/providers/index.ts b/ccip-cli/src/providers/index.ts index a65bf300a..64cd11592 100644 --- a/ccip-cli/src/providers/index.ts +++ b/ccip-cli/src/providers/index.ts @@ -10,6 +10,7 @@ import { type NetworkInfo, type TONChain, CCIPChainFamilyUnsupportedError, + CCIPError, CCIPRpcNotFoundError, CCIPTransactionNotFoundError, ChainFamily, @@ -23,11 +24,13 @@ import type { Ctx } from '../commands/index.ts' import type { GlobalOpts } from '../index.ts' import { loadAptosWallet } from './aptos.ts' import { + type CantonCliConfig, loadCantonConfig, loadCantonWallet, + resolveCantonTokenGetter, resolveCliIndexer, resolveCliRouter, -} from './canton.ts' +} from './canton/index.ts' import { loadEvmWallet } from './evm.ts' import { loadSolanaWallet } from './solana.ts' import { loadSuiWallet } from './sui.ts' @@ -150,7 +153,7 @@ export function fetchChainsFromRpcs( * @returns a ChainGetter (if txHash was provided), or a tuple of [ChainGetter, Promise] */ export function fetchChainsFromRpcs(ctx: Ctx, argv: FetchGlobalArgs, txHash?: string) { - const cantonConfig = loadCantonConfig(argv.cantonConfig, ctx.logger) + const rawCantonConfig = loadCantonConfig(argv.cantonConfig, ctx.logger) const chains: Record> = {} const pendingChainsCbs: Record< string, @@ -161,92 +164,144 @@ export function fetchChainsFromRpcs(ctx: Ctx, argv: FetchGlobalArgs, txHash?: st let endpoints$: Promise> | undefined let txFoundIn: string | undefined + /** + * Resolve the Canton config's `auth` block (if present) into a `jwt` (string + * or getter) upfront, so the SDK never orchestrates an OAuth flow. The + * resolution is lazy (only awaited when the Canton family is loaded) and + * memoized so repeated family loads reuse the same resolved config. + * + * When no `auth` block is present, the raw config (with its static `jwt`) + * is used as-is. + */ + type ResolvedCantonConfig = Omit | undefined + let resolvedCantonConfig$: Promise | undefined + const getResolvedCantonConfig = (): Promise => { + if (!rawCantonConfig) return Promise.resolve(undefined) + if (!rawCantonConfig.auth) return Promise.resolve(rawCantonConfig as ResolvedCantonConfig) + return (resolvedCantonConfig$ ??= resolveCantonTokenGetter(rawCantonConfig.auth, { + signal: ctx.abort, + }).then((jwt) => { + // `auth` is consumed here; the SDK receives only `jwt`. + const { auth: _auth, ...rest } = rawCantonConfig + void _auth + return { ...rest, jwt } + })) + } + const loadChainFamily = (F: ChainFamily, txHash?: string) => - (initFamily$[F] ??= (endpoints$ ??= collectEndpoints.call(ctx, argv)).then((endpoints) => { - const C = supportedChains[F] - if (!C) throw new CCIPChainFamilyUnsupportedError(F) - ctx.abort.throwIfAborted() - const familyEndpoints = filterEndpointsForFamily(endpoints, F) - ctx.logger.debug( - 'Racing', - familyEndpoints.size, - 'RPC endpoints for', - F, - familyEndpoints.size < endpoints.size ? `(filtered from ${endpoints.size})` : '', - ) + (initFamily$[F] ??= (endpoints$ ??= collectEndpoints.call(ctx, argv)).then( + async (endpoints) => { + const C = supportedChains[F] + if (!C) throw new CCIPChainFamilyUnsupportedError(F) + ctx.abort.throwIfAborted() + const familyEndpoints = filterEndpointsForFamily(endpoints, F) + ctx.logger.debug( + 'Racing', + familyEndpoints.size, + 'RPC endpoints for', + F, + familyEndpoints.size < endpoints.size ? `(filtered from ${endpoints.size})` : '', + ) - const chains$: Promise[] = [] - const txOnlyRacers = new WeakSet() - for (const url of familyEndpoints) { - const chain$ = C.fromUrl(url, { - ...ctx, - abort: ctx.abort, - apiClient: - argv.api === false ? null : typeof argv.api === 'string' ? argv.api : undefined, - ...(cantonConfig && F === ChainFamily.Canton && { cantonConfig }), - }) - chains$.push(chain$) + const chains$: Promise[] = [] + const txOnlyRacers = new WeakSet() + // For Canton, await the upfront auth resolution (auth block → jwt) + // before spawning racers, so the SDK never orchestrates an OAuth flow. + const cantonConfigForFamily = + F === ChainFamily.Canton ? await getResolvedCantonConfig() : undefined + for (const url of familyEndpoints) { + const chain$ = C.fromUrl(url, { + ...ctx, + abort: ctx.abort, + apiClient: + argv.api === false ? null : typeof argv.api === 'string' ? argv.api : undefined, + ...(cantonConfigForFamily && + F === ChainFamily.Canton && { + cantonConfig: cantonConfigForFamily, + }), + }) + chains$.push(chain$) - void chain$.then( - (chain) => { - endpoints.delete(url) // when resolved, remove from set so it isn't tried for future families - // winner: provider cleanup is handled automatically by ctx.abort signal - if (!(chain.network.name in chains)) { - // chain won for this network, but was not "asked" by getChain (yet?): save - chains[chain.network.name] = chain$ - } else if (chain.network.name in pendingChainsCbs) { - // chain detected, and there's a "pending request" by getChain: resolve - const [resolve] = pendingChainsCbs[chain.network.name]! - resolve(chain) - } else if (!txHash || txFoundIn) { - chain.destroy() // lost race (either network's or tx's) - } else { - txOnlyRacers.add(chain) // lost race, but may still find tx before winner and take its place - } - }, - () => {}, - ) - } - let txs$ - if (txHash) { - txs$ = Promise.any( - chains$.map(async (chain$) => { - const chain = await chain$ - chain.abort.throwIfAborted() - try { - if (txFoundIn) throw new Error('tx already raced') - const tx = await chain.getTransaction(txHash) - if (txFoundIn) { - if (txFoundIn === chain.network.name) chain.destroy() - throw new Error('tx already raced') + void chain$.then( + (chain) => { + endpoints.delete(url) // when resolved, remove from set so it isn't tried for future families + // winner: provider cleanup is handled automatically by ctx.abort signal + if (!(chain.network.name in chains)) { + // chain won for this network, but was not "asked" by getChain (yet?): save + chains[chain.network.name] = chain$ + } else if (chain.network.name in pendingChainsCbs) { + // chain detected, and there's a "pending request" by getChain: resolve + const [resolve] = pendingChainsCbs[chain.network.name]! + resolve(chain) + } else if (!txHash || txFoundIn) { + chain.destroy() // lost race (either network's or tx's) + } else { + txOnlyRacers.add(chain) // lost race, but may still find tx before winner and take its place + } + }, + () => {}, + ) + } + let txs$ + if (txHash) { + txs$ = Promise.any( + chains$.map(async (chain$) => { + const chain = await chain$ + chain.abort.throwIfAborted() + try { + if (txFoundIn) throw new Error('tx already raced') + const tx = await chain.getTransaction(txHash) + if (txFoundIn) { + if (txFoundIn === chain.network.name) chain.destroy() + throw new Error('tx already raced') + } + txFoundIn = chain.network.name + // in case tx is first found, prefer it over any previously found chain for this network + chains[chain.network.name] = chain$ + return [chain, tx] as const + } catch (err) { + if (txOnlyRacers.has(chain)) chain.destroy() + throw err + } + }), + ) + } + + Promise.race([ + Promise.allSettled(chains$).then((results) => { + // When all RPCs for a family fail, surface the most specific error + // (e.g. CANTON_AUTH_ERROR) instead of a generic RPC_NOT_FOUND. + if (finished[F]) return + finished[F] = true + // Find the most informative rejection reason from the settled results. + // Prefer CCIPError with specific codes over generic errors. + let bestError: Error | undefined + for (const result of results) { + if (result.status === 'rejected' && result.reason instanceof Error) { + if (!bestError || CCIPError.isCCIPError(result.reason)) { + bestError = result.reason + } } - txFoundIn = chain.network.name - // in case tx is first found, prefer it over any previously found chain for this network - chains[chain.network.name] = chain$ - return [chain, tx] as const - } catch (err) { - if (txOnlyRacers.has(chain)) chain.destroy() - throw err } + Object.entries(pendingChainsCbs) + .filter(([name]) => networkInfo(name).family === F) + .forEach(([name, [_, reject]]) => { + if (bestError && CCIPError.isCCIPError(bestError)) { + reject(bestError) + } else { + reject(new CCIPRpcNotFoundError(name)) + } + }) }), - ) - } - - Promise.race([Promise.allSettled(chains$), signalToPromise(ctx.abort)]) - .finally(() => { - if (finished[F]) return - finished[F] = true - Object.entries(pendingChainsCbs) - .filter(([name]) => networkInfo(name).family === F) - .forEach(([name, [_, reject]]) => reject(new CCIPRpcNotFoundError(name))) - }) - .catch(() => { + signalToPromise(ctx.abort), + ]).catch(() => { // signalToPromise(ctx.abort) rejects with DOMException when the parent // context aborts before all race URLs settle; swallow it here so the // void-discarded chain doesn't surface as an unhandled rejection. }) - return txs$ - })) + return txs$ + }, + )) const chainGetter = async (idOrSelectorOrName: number | string | bigint): Promise => { const network = networkInfo(idOrSelectorOrName) diff --git a/ccip-sdk/package.json b/ccip-sdk/package.json index 0518465ed..b0c536111 100644 --- a/ccip-sdk/package.json +++ b/ccip-sdk/package.json @@ -83,6 +83,7 @@ "buffer": "^6.0.3", "ethers": "6.17.0", "micro-memoize": "^5.1.2", + "oauth4webapi": "^3.8.7", "type-fest": "^5.8.0", "yaml": "2.9.0" }, diff --git a/ccip-sdk/src/canton/authentication/authentication.test.ts b/ccip-sdk/src/canton/authentication/authentication.test.ts new file mode 100644 index 000000000..b3b93d708 --- /dev/null +++ b/ccip-sdk/src/canton/authentication/authentication.test.ts @@ -0,0 +1,747 @@ +/** + * Unit tests for the Canton authentication providers. + * + * Test approach: + * - Mock token endpoints via a lightweight `http.Server` (no external deps). + * - Validate request form params (grant_type, scope, audience, code_verifier). + * - For the authorization code flow, test the runtime-agnostic protocol + * primitives (build-authorize-URL, callback validation, code→token exchange) + * directly — the Node-specific callback server / browser orchestration is + * tested in the CLI (`providers/canton/`). + */ +import assert from 'node:assert/strict' +import { type Server, createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import { describe, it } from 'node:test' + +import { CCIPError } from '../../errors/index.ts' +import { + codeChallengeFromVerifier, + createMemoizedTokenFetcher, + generateCodeVerifier, + generateState, +} from './token-source.ts' +import { + AuthType, + buildAuthorizationRequest, + createAuthProvider, + createAuthorizationCodeProvider, + createStaticProvider, + exchangeAuthorizationCode, + isAccessToken, + isTokenExpired, + validateAuthorizationCallback, +} from './index.ts' + +// --------------------------------------------------------------------------- +// Helpers — mock OAuth2 token + metadata servers +// --------------------------------------------------------------------------- + +/** Parsed form body of the last token request received by the mock server. */ +interface CapturedTokenRequest { + method: string + url: string + body: Record + headers: Record +} + +/** + * Start a mock token endpoint that captures the request and returns a canned token. + * + * @param response - Token JSON to return (default: a valid access token). + * @param status - HTTP status (default 200). + * @param validate - Optional callback to assert on the captured request. + */ +function startTokenServer(opts: { + response?: Record + status?: number + validate?: (req: CapturedTokenRequest) => void +}): Promise<{ server: Server; url: string; requests: CapturedTokenRequest[] }> { + const requests: CapturedTokenRequest[] = [] + const server = createServer((req, res) => { + let body = '' + req.on('data', (chunk) => (body += chunk)) + req.on('end', () => { + const parsed = Object.fromEntries(new URLSearchParams(body)) + const captured: CapturedTokenRequest = { + method: req.method ?? '', + url: req.url ?? '', + body: parsed, + headers: req.headers, + } + requests.push(captured) + opts.validate?.(captured) + const status = opts.status ?? 200 + const json = + opts.response ?? + ({ access_token: 'test-access-token', token_type: 'Bearer', expires_in: 3600 } as const) + res.writeHead(status, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(json)) + }) + }) + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() as AddressInfo + resolve({ server, url: `http://127.0.0.1:${port}`, requests }) + }) + }) +} + +/** + * Start a mock authorization-server metadata endpoint. + */ +function startMetadataServer(opts: { + tokenEndpoint?: string + authorizationEndpoint?: string + codeChallengeMethods?: string[] + issuer?: string + status?: number +}): Promise<{ server: Server; baseUrl: string }> { + const server = createServer((req, res) => { + if (req.url !== '/.well-known/oauth-authorization-server') { + res.writeHead(404) + res.end('not found') + return + } + const status = opts.status ?? 200 + if (status !== 200) { + res.writeHead(status, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'metadata unavailable' })) + return + } + const baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + const json = { + issuer: opts.issuer ?? baseUrl, + token_endpoint: opts.tokenEndpoint ?? `${baseUrl}/v1/token`, + authorization_endpoint: opts.authorizationEndpoint ?? `${baseUrl}/v1/authorize`, + code_challenge_methods_supported: opts.codeChallengeMethods ?? ['S256'], + } + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(json)) + }) + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() as AddressInfo + resolve({ server, baseUrl: `http://127.0.0.1:${port}` }) + }) + }) +} + +/** Fetch wrapper that records calls (for threading into providers). */ +function makeFetchSpy(): { fetch: typeof fetch; calls: string[] } { + const calls: string[] = [] + const spy: typeof fetch = async (input, init) => { + const url = input instanceof Request ? input.url : String(input) + calls.push(url) + return fetch(input, init) + } + return { fetch: spy, calls } +} + +// --------------------------------------------------------------------------- +// types.ts +// --------------------------------------------------------------------------- + +describe('canton/authentication — types', () => { + it('isAccessToken validates shape', () => { + assert.equal(isAccessToken({ accessToken: 'abc' }), true) + assert.equal(isAccessToken({ accessToken: '' }), false) + assert.equal(isAccessToken({ token: 'abc' }), false) + assert.equal(isAccessToken(null), false) + }) + + it('AuthType constants match Go commonconfig values', () => { + assert.equal(AuthType.Static, 'static') + assert.equal(AuthType.ClientCredentials, 'clientCredentials') + assert.equal(AuthType.AuthorizationCode, 'authorizationCode') + }) +}) + +// --------------------------------------------------------------------------- +// token-source.ts +// --------------------------------------------------------------------------- + +describe('canton/authentication — token-source primitives', () => { + it('isTokenExpired returns true for missing token', () => { + assert.equal(isTokenExpired(undefined), true) + }) + + it('isTokenExpired returns false for token with no expiry', () => { + assert.equal(isTokenExpired({ accessToken: 'x' }), false) + }) + + it('isTokenExpired returns true past expiry (with skew)', () => { + const past = { accessToken: 'x', expiresAt: Date.now() - 1000 } + assert.equal(isTokenExpired(past), true) + }) + + it('isTokenExpired returns false well before expiry', () => { + const future = { accessToken: 'x', expiresAt: Date.now() + 60_000 } + assert.equal(isTokenExpired(future), false) + }) + + it('createMemoizedTokenFetcher fetches once and caches', async () => { + let fetchCount = 0 + const fetchToken = createMemoizedTokenFetcher(async () => { + fetchCount++ + return { accessToken: `tok-${fetchCount}`, expiresAt: Date.now() + 60_000 } + }) + assert.equal((await fetchToken()).accessToken, 'tok-1') + assert.equal((await fetchToken()).accessToken, 'tok-1') // cached + assert.equal(fetchCount, 1) + }) + + it('createMemoizedTokenFetcher re-fetches when expired', async () => { + let fetchCount = 0 + const fetchToken = createMemoizedTokenFetcher(async () => { + fetchCount++ + return { accessToken: `tok-${fetchCount}`, expiresAt: Date.now() - 1000 } // already expired + }) + await fetchToken() + await fetchToken() + assert.equal(fetchCount, 2) + }) + + it('createMemoizedTokenFetcher coalesces concurrent fetches', async () => { + let fetchCount = 0 + const fetchToken = createMemoizedTokenFetcher(async () => { + fetchCount++ + // simulate latency + await new Promise((r) => setTimeout(r, 10)) + return { accessToken: `tok-${fetchCount}`, expiresAt: Date.now() + 60_000 } + }) + const [a, b, c] = await Promise.all([fetchToken(), fetchToken(), fetchToken()]) + assert.equal(fetchCount, 1, 'concurrent calls should share a single fetch') + assert.equal(a.accessToken, b.accessToken) + assert.equal(b.accessToken, c.accessToken) + }) + + it('createMemoizedTokenFetcher uses initial token without fetching', async () => { + let fetchCount = 0 + const initial = { accessToken: 'initial-tok', expiresAt: Date.now() + 60_000 } + const fetchToken = createMemoizedTokenFetcher(async () => { + fetchCount++ + return { accessToken: `tok-${fetchCount}`, expiresAt: Date.now() + 60_000 } + }, initial) + assert.equal((await fetchToken()).accessToken, 'initial-tok') + assert.equal(fetchCount, 0, 'should not fetch when initial token is still valid') + }) + + it('generateCodeVerifier produces a non-empty base64url string', () => { + const v = generateCodeVerifier() + assert.ok(v.length >= 43, `verifier too short: ${v.length}`) + assert.ok(/^[A-Za-z0-9_-]+$/.test(v), 'verifier must be base64url (no padding)') + }) + + it('codeChallengeFromVerifier produces S256 challenge', async () => { + const verifier = 'dGVzdA' // "test" base64url-ish + const challenge = await codeChallengeFromVerifier(verifier) + assert.ok(challenge.length > 0) + // S256 challenge is base64url(sha256(verifier)) — 43 chars for 32-byte digest + assert.equal(challenge.length, 43) + }) + + it('generateState produces unique values', () => { + const a = generateState() + const b = generateState() + assert.notEqual(a, b) + assert.ok(a.length > 0) + }) +}) + +// --------------------------------------------------------------------------- +// static.ts +// --------------------------------------------------------------------------- + +describe('canton/authentication — static providers', () => { + it('createStaticProvider returns a token yielding the JWT', async () => { + const provider = createStaticProvider('my-jwt-123') + assert.equal(provider.type, AuthType.Static) + assert.equal((await provider.token()).accessToken, 'my-jwt-123') + }) + + it('static providers reject empty JWT', () => { + assert.throws(() => createStaticProvider(''), CCIPError) + assert.throws(() => createStaticProvider(' '), CCIPError) + }) +}) + +// --------------------------------------------------------------------------- +// client-credentials.ts +// --------------------------------------------------------------------------- + +describe('canton/authentication — client credentials flow', () => { + it('fromDirect fetches a token via the client_credentials grant', async () => { + const { server, url, requests } = await startTokenServer({ + validate: (req) => { + assert.equal(req.body['grant_type'], 'client_credentials') + assert.equal(req.body['client_id'], 'cid') + assert.equal(req.body['client_secret'], 'secret') + assert.equal(req.body['scope'], 'daml_ledger_api') + }, + }) + try { + const { ClientCredentialsProvider } = await import('./client-credentials.ts') + const provider = ClientCredentialsProvider.fromDirect( + { + type: AuthType.ClientCredentials, + authUrl: url, + tokenUrl: `${url}/v1/token`, + clientId: 'cid', + clientSecret: 'secret', + }, + { allowInsecureRequests: true }, + ) + const token = await provider.token() + assert.equal(token.accessToken, 'test-access-token') + assert.equal(requests.length, 1) + } finally { + server.close() + } + }) + + it('WithAudience sends the audience form param', async () => { + const { server, url, requests } = await startTokenServer({ + validate: (req) => { + assert.equal(req.body['audience'], 'https://ledger.example.com') + }, + }) + try { + const { ClientCredentialsProvider } = await import('./client-credentials.ts') + const provider = ClientCredentialsProvider.fromDirect( + { + type: AuthType.ClientCredentials, + authUrl: url, + tokenUrl: `${url}/v1/token`, + clientId: 'cid', + clientSecret: 'secret', + audience: 'https://ledger.example.com', + }, + { allowInsecureRequests: true }, + ) + await provider.token() + assert.equal(requests.length, 1) + } finally { + server.close() + } + }) + + it('fromDiscovery uses metadata token_endpoint', async () => { + const tokenServer = await startTokenServer({}) + const metaServer = await startMetadataServer({ + tokenEndpoint: `${tokenServer.url}/v1/token`, + }) + try { + const { ClientCredentialsProvider } = await import('./client-credentials.ts') + const provider = await ClientCredentialsProvider.fromDiscovery( + { + type: AuthType.ClientCredentials, + authUrl: metaServer.baseUrl, + clientId: 'cid', + clientSecret: 'secret', + }, + { allowInsecureRequests: true }, + ) + const token = await provider.token() + assert.equal(token.accessToken, 'test-access-token') + } finally { + tokenServer.server.close() + metaServer.server.close() + } + }) + + it('fromDiscovery fails on non-200 metadata', async () => { + const metaServer = await startMetadataServer({ status: 500 }) + try { + const { ClientCredentialsProvider } = await import('./client-credentials.ts') + await assert.rejects( + ClientCredentialsProvider.fromDiscovery( + { + type: AuthType.ClientCredentials, + authUrl: metaServer.baseUrl, + clientId: 'cid', + clientSecret: 'secret', + }, + { allowInsecureRequests: true }, + ), + CCIPError, + ) + } finally { + metaServer.server.close() + } + }) + + it('rejects empty config fields', async () => { + const { ClientCredentialsProvider } = await import('./client-credentials.ts') + assert.throws( + () => + ClientCredentialsProvider.fromDirect( + { + type: AuthType.ClientCredentials, + authUrl: '', + tokenUrl: 'x', + clientId: '', + clientSecret: '', + }, + { allowInsecureRequests: true }, + ), + CCIPError, + ) + }) + + it('caches the token across multiple token() calls', async () => { + const { server, url, requests } = await startTokenServer({}) + try { + const { ClientCredentialsProvider } = await import('./client-credentials.ts') + const provider = ClientCredentialsProvider.fromDirect( + { + type: AuthType.ClientCredentials, + authUrl: url, + tokenUrl: `${url}/v1/token`, + clientId: 'cid', + clientSecret: 'secret', + }, + { allowInsecureRequests: true }, + ) + await provider.token() + await provider.token() + assert.equal(requests.length, 1, 'token should be cached') + } finally { + server.close() + } + }) +}) + +// --------------------------------------------------------------------------- +// authorization-code.ts (runtime-agnostic protocol primitives) +// --------------------------------------------------------------------------- + +describe('canton/authentication — authorization code protocol primitives', () => { + it('buildAuthorizationRequest produces an authorize URL with PKCE + state', async () => { + const metaServer = await startMetadataServer({}) + try { + const req = await buildAuthorizationRequest( + { + type: AuthType.AuthorizationCode, + authUrl: metaServer.baseUrl, + clientId: 'cid', + callbackUrl: 'http://127.0.0.1:9999/callback', + }, + { + stateOverride: 'known-state', + verifierOverride: 'known-verifier', + allowInsecureRequests: true, + }, + ) + assert.ok(req.authorizeUrl.includes('response_type=code')) + assert.ok(req.authorizeUrl.includes('client_id=cid')) + assert.ok(req.authorizeUrl.includes('code_challenge_method=S256')) + assert.ok(req.authorizeUrl.includes('state=known-state')) + assert.ok(req.authorizeUrl.includes('redirect_uri='), 'authorize URL includes redirect_uri') + assert.equal(req.state, 'known-state') + assert.equal(req.verifier, 'known-verifier') + assert.equal(req.codeChallenge.length, 43, 'S256 challenge is 43 chars') + assert.equal(req.redirectUri, 'http://127.0.0.1:9999/callback') + } finally { + metaServer.server.close() + } + }) + + it('buildAuthorizationRequest includes audience when set', async () => { + const metaServer = await startMetadataServer({}) + try { + const req = await buildAuthorizationRequest( + { + type: AuthType.AuthorizationCode, + authUrl: metaServer.baseUrl, + clientId: 'cid', + callbackUrl: 'http://127.0.0.1:9999/callback', + audience: 'https://ledger.example.com', + }, + { allowInsecureRequests: true }, + ) + assert.ok(req.authorizeUrl.includes('audience=https')) + } finally { + metaServer.server.close() + } + }) + + it('buildAuthorizationRequest rejects when callbackUrl is missing', async () => { + const metaServer = await startMetadataServer({}) + try { + await assert.rejects( + buildAuthorizationRequest( + { + type: AuthType.AuthorizationCode, + authUrl: metaServer.baseUrl, + clientId: 'cid', + }, + { allowInsecureRequests: true }, + ), + CCIPError, + ) + } finally { + metaServer.server.close() + } + }) + + it('buildAuthorizationRequest rejects when S256 is unsupported', async () => { + const metaServer = await startMetadataServer({ codeChallengeMethods: ['plain'] }) + try { + await assert.rejects( + buildAuthorizationRequest( + { + type: AuthType.AuthorizationCode, + authUrl: metaServer.baseUrl, + clientId: 'cid', + callbackUrl: 'http://127.0.0.1:9999/callback', + }, + { allowInsecureRequests: true }, + ), + /S256/, + ) + } finally { + metaServer.server.close() + } + }) + + it('validateAuthorizationCallback extracts the code when state matches', async () => { + const metaServer = await startMetadataServer({}) + try { + const req = await buildAuthorizationRequest( + { + type: AuthType.AuthorizationCode, + authUrl: metaServer.baseUrl, + clientId: 'cid', + callbackUrl: 'http://127.0.0.1:9999/callback', + }, + { stateOverride: 'known-state', allowInsecureRequests: true }, + ) + const callbackUrl = `${req.redirectUri}?code=test-code&state=${req.state}` + const { code, state } = await validateAuthorizationCallback( + { + type: AuthType.AuthorizationCode, + authUrl: metaServer.baseUrl, + clientId: 'cid', + callbackUrl: req.redirectUri, + }, + callbackUrl, + req.state, + { allowInsecureRequests: true }, + ) + assert.equal(code, 'test-code') + assert.equal(state, req.state) + } finally { + metaServer.server.close() + } + }) + + it('validateAuthorizationCallback rejects on state mismatch', async () => { + const metaServer = await startMetadataServer({}) + try { + await assert.rejects( + validateAuthorizationCallback( + { + type: AuthType.AuthorizationCode, + authUrl: metaServer.baseUrl, + clientId: 'cid', + callbackUrl: 'http://127.0.0.1:9999/callback', + }, + 'http://127.0.0.1:9999/callback?code=x&state=wrong', + 'correct-state', + { allowInsecureRequests: true }, + ), + CCIPError, + ) + } finally { + metaServer.server.close() + } + }) + + it('validateAuthorizationCallback rejects on OAuth error redirect', async () => { + const metaServer = await startMetadataServer({}) + try { + await assert.rejects( + validateAuthorizationCallback( + { + type: AuthType.AuthorizationCode, + authUrl: metaServer.baseUrl, + clientId: 'cid', + callbackUrl: 'http://127.0.0.1:9999/callback', + }, + 'http://127.0.0.1:9999/callback?error=access_denied&error_description=user+denied', + 'correct-state', + { allowInsecureRequests: true }, + ), + /user denied/, + ) + } finally { + metaServer.server.close() + } + }) + + it('exchangeAuthorizationCode exchanges the code for tokens', async () => { + const tokenServer = await startTokenServer({ + response: { access_token: 'auth-code-token', token_type: 'Bearer', expires_in: 3600 }, + validate: (req) => { + assert.equal(req.body['grant_type'], 'authorization_code') + assert.ok(req.body['code'], 'must include code') + assert.ok(req.body['code_verifier'], 'must include code_verifier (PKCE)') + assert.equal(req.body['client_id'], 'cid') + }, + }) + const metaServer = await startMetadataServer({ + tokenEndpoint: `${tokenServer.url}/v1/token`, + authorizationEndpoint: `${tokenServer.url}/v1/authorize`, + }) + try { + const config = { + type: AuthType.AuthorizationCode, + authUrl: metaServer.baseUrl, + clientId: 'cid', + callbackUrl: 'http://127.0.0.1:9999/callback', + } + const req = await buildAuthorizationRequest(config, { + stateOverride: 'known-state', + verifierOverride: 'known-verifier', + allowInsecureRequests: true, + }) + const callbackUrl = `${req.redirectUri}?code=test-code&state=${req.state}` + const callback = await validateAuthorizationCallback(config, callbackUrl, req.state, { + allowInsecureRequests: true, + }) + const token = await exchangeAuthorizationCode( + config, + callback, + req.verifier, + req.redirectUri, + { allowInsecureRequests: true }, + ) + assert.equal(token.accessToken, 'auth-code-token') + // oauth4webapi normalizes token_type to lowercase + assert.match(token.tokenType ?? '', /^bearer$/i) + assert.ok(token.expiresAt, 'expiresAt derived from expires_in') + } finally { + tokenServer.server.close() + metaServer.server.close() + } + }) + + it('createAuthorizationCodeProvider wraps an initial token and refreshes via callback', async () => { + const metaServer = await startMetadataServer({}) + try { + const config = { + type: AuthType.AuthorizationCode, + authUrl: metaServer.baseUrl, + clientId: 'cid', + callbackUrl: 'http://127.0.0.1:9999/callback', + } + let fetchCount = 0 + const initialToken = { + accessToken: 'initial-token', + expiresAt: Date.now() - 1000, // already expired → triggers refresh + } + const provider = createAuthorizationCodeProvider( + config, + initialToken, + async () => { + fetchCount++ + return { accessToken: `refetched-${fetchCount}`, expiresAt: Date.now() + 60_000 } + }, + { allowInsecureRequests: true }, + ) + // Token is expired → doRefresh falls back to fetchToken (no refresh token). + const token = await provider.token() + assert.equal(token.accessToken, 'refetched-1') + assert.equal(fetchCount, 1) + } finally { + metaServer.server.close() + } + }) + + it('rejects empty config fields', async () => { + await assert.rejects( + buildAuthorizationRequest({ + type: AuthType.AuthorizationCode, + authUrl: '', + clientId: '', + callbackUrl: 'http://127.0.0.1:9999/callback', + }), + CCIPError, + ) + }) +}) + +// --------------------------------------------------------------------------- +// index.ts — createAuthProvider +// --------------------------------------------------------------------------- + +describe('canton/authentication — createAuthProvider', () => { + it('createAuthProvider defaults to static when type omitted', async () => { + const provider = await createAuthProvider({ jwt: 'static-jwt' }) + assert.equal(provider.type, AuthType.Static) + assert.equal((await provider.token()).accessToken, 'static-jwt') + }) + + it('createAuthProvider builds a static provider', async () => { + const provider = await createAuthProvider({ type: AuthType.Static, jwt: 'j1' }) + assert.equal(provider.type, AuthType.Static) + assert.equal((await provider.token()).accessToken, 'j1') + }) + + it('createAuthProvider rejects unsupported type', async () => { + await assert.rejects( + createAuthProvider({ type: 'unknown', jwt: 'x' } as unknown as Parameters< + typeof createAuthProvider + >[0]), + CCIPError, + ) + }) + + it('createAuthProvider builds a clientCredentials provider via discovery', async () => { + const tokenServer = await startTokenServer({}) + const metaServer = await startMetadataServer({ tokenEndpoint: `${tokenServer.url}/v1/token` }) + try { + const provider = await createAuthProvider( + { + type: AuthType.ClientCredentials, + authUrl: metaServer.baseUrl, + clientId: 'cid', + clientSecret: 'secret', + }, + { allowInsecureRequests: true }, + ) + assert.equal(provider.type, AuthType.ClientCredentials) + const token = await provider.token() + assert.equal(token.accessToken, 'test-access-token') + } finally { + tokenServer.server.close() + metaServer.server.close() + } + }) + + it('custom fetch is threaded through to discovery + token fetch', async () => { + const tokenServer = await startTokenServer({}) + const metaServer = await startMetadataServer({ tokenEndpoint: `${tokenServer.url}/v1/token` }) + const { fetch: spyFetch, calls } = makeFetchSpy() + try { + const provider = await createAuthProvider( + { + type: AuthType.ClientCredentials, + authUrl: metaServer.baseUrl, + clientId: 'cid', + clientSecret: 'secret', + }, + { fetch: spyFetch, allowInsecureRequests: true }, + ) + await provider.token() + // At least 2 calls: metadata discovery + token fetch + assert.ok(calls.length >= 2, `expected >= 2 spy calls, got ${calls.length}`) + assert.ok(calls.some((u) => u.includes('.well-known'))) + assert.ok(calls.some((u) => u.includes('/v1/token'))) + } finally { + tokenServer.server.close() + metaServer.server.close() + } + }) +}) diff --git a/ccip-sdk/src/canton/authentication/authorization-code.ts b/ccip-sdk/src/canton/authentication/authorization-code.ts new file mode 100644 index 000000000..c66c64e8b --- /dev/null +++ b/ccip-sdk/src/canton/authentication/authorization-code.ts @@ -0,0 +1,472 @@ +import * as oauth from 'oauth4webapi' + +import { CCIPError, CCIPErrorCode } from '../../errors/index.ts' +import { discoverAuthorizationServer } from './metadata.ts' +import { + type OAuthRequestOptions, + buildOAuthRequestOptions, + codeChallengeFromVerifier, + createMemoizedTokenFetcher, + generateCodeVerifier, + generateState, + toAccessToken, + wrapOAuthError, +} from './token-source.ts' +import type { AccessToken, AuthProvider, AuthorizationCodeAuthConfig } from './types.ts' + +/** + * OAuth2 authorization code + PKCE protocol primitives (runtime-agnostic). + * + * @packageDocumentation + * + * This module implements **only the protocol pieces** of the authorization + * code grant (RFC 6749 §4.1) with PKCE (RFC 7636, S256) that an embedder + * cannot safely rewrite: building the authorize URL, validating the callback + * (state + PKCE), exchanging the code for tokens, and refreshing via the + * `refresh_token` grant. + * + * It is deliberately **runtime-agnostic**: it uses only `fetch` and WebCrypto + * (via `oauth4webapi`) and imports no `node:*` modules. The environment-specific + * orchestration — spawning a local callback server, opening a browser, + * resolving `CANTON_CLIENT_ID` / `CANTON_CLIENT_SECRET` from env vars — lives + * in the CLI (`providers/canton/`), which composes these primitives and hands + * the resulting JWT (or a `() => Promise` token getter) to + * {@link CantonConfig}. + * + * Web/Electron embedders compose these primitives with their own + * redirect/callback handling. + * + * @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1 + * @see https://datatracker.ietf.org/doc/html/rfc7636 + * @see https://datatracker.ietf.org/doc/html/rfc8414 + */ + +/** Default scopes for the authorization code flow. */ +const DEFAULT_AUTHORIZATION_CODE_SCOPES = ['openid', 'daml_ledger_api'] + +/** + * Resolved authorization-code provider configuration (after applying defaults). + */ +interface ResolvedAuthorizationCodeConfig { + as: oauth.AuthorizationServer + client: oauth.Client + scopes: string[] + audience: string + redirectUri: string + fetch?: typeof fetch + signal?: AbortSignal + allowInsecureRequests?: boolean +} + +/** + * PKCE + state material generated for an authorization request. + * + * The embedder keeps the `verifier` and `state` secret (server-side / in + * process memory) and uses them to validate the callback and exchange the code. + * The `codeChallenge` and `authorizeUrl` are safe to send to the browser. + */ +export interface AuthorizationRequest { + /** The full authorization endpoint URL to redirect the user to. */ + authorizeUrl: string + /** The PKCE code verifier — keep secret; required for the token exchange. */ + verifier: string + /** The S256 code challenge derived from `verifier` (sent in the authorize URL). */ + codeChallenge: string + /** The OAuth2 `state` parameter — keep secret; required to validate the callback. */ + state: string + /** The redirect URI the authorization server will redirect back to. */ + redirectUri: string +} + +/** + * A validated authorization callback: the code + state extracted from the + * redirect URL after {@link validateAuthorizationCallback} succeeds. + * + * The `callbackParams` field carries the branded `URLSearchParams` instance + * returned by `oauth4webapi.validateAuthResponse`; it MUST be passed to + * {@link exchangeAuthorizationCode} (the underlying library brand-checks it). + */ +export interface ValidatedCallback { + /** The authorization code to exchange for tokens. */ + code: string + /** The state echoed back by the server (already verified to match). */ + state: string + /** + * The branded `URLSearchParams` from `validateAuthResponse` — pass this to + * {@link exchangeAuthorizationCode}. Do not reconstruct it. + */ + callbackParams: URLSearchParams +} + +/** + * Options shared by the protocol helpers (fetch override, abort signal, + * insecure-requests toggle for testing). + */ +export type AuthorizationCodeProtocolOptions = OAuthRequestOptions + +/** + * Validate the shared authorization-code config fields. + */ +function validateAuthorizationCodeConfig(config: AuthorizationCodeAuthConfig): void { + if (!config.authUrl.trim()) { + throw new CCIPError( + CCIPErrorCode.CANTON_AUTH_ERROR, + 'authorizationCode auth requires a non-empty authUrl', + ) + } + if (!config.clientId.trim()) { + throw new CCIPError( + CCIPErrorCode.CANTON_AUTH_ERROR, + 'authorizationCode auth requires a non-empty clientId', + ) + } +} + +/** + * Resolve an {@link AuthorizationCodeAuthConfig} into a + * {@link ResolvedAuthorizationCodeConfig} using RFC 8414 metadata discovery. + * + * Requires the server to advertise S256 PKCE support and an + * `authorization_endpoint`. + * + * @param config - Authorization code config with `authUrl`. + * @param options - Optional fetch override and abort signal. + * @returns The resolved config (authorization server metadata + client + defaults). + * @throws {@link CCIPError} (CANTON_AUTH_ERROR) on discovery failure, missing + * S256 support, or missing `authorization_endpoint`. + * + * @see https://datatracker.ietf.org/doc/html/rfc8414 + */ +export async function resolveAuthorizationCodeConfig( + config: AuthorizationCodeAuthConfig, + options?: AuthorizationCodeProtocolOptions, +): Promise { + validateAuthorizationCodeConfig(config) + const as = await discoverAuthorizationServer(config.authUrl, { + fetch: options?.fetch, + signal: options?.signal, + allowInsecureRequests: options?.allowInsecureRequests, + }) + if (!as.code_challenge_methods_supported?.includes('S256')) { + throw new CCIPError( + CCIPErrorCode.CANTON_AUTH_ERROR, + 'Authorization server does not support S256 PKCE challenges', + ) + } + if (!as.authorization_endpoint) { + throw new CCIPError( + CCIPErrorCode.CANTON_AUTH_ERROR, + 'Authorization server metadata is missing an authorization_endpoint', + ) + } + return { + as, + client: { client_id: config.clientId }, + scopes: config.scopes?.length ? config.scopes : DEFAULT_AUTHORIZATION_CODE_SCOPES, + audience: config.audience ?? '', + redirectUri: config.callbackUrl ?? '', + fetch: options?.fetch, + signal: options?.signal, + allowInsecureRequests: options?.allowInsecureRequests, + } +} + +/** + * Build the authorization request: authorize URL + PKCE verifier + state. + * + * This is the first step of the interactive flow. The embedder redirects the + * user's browser to `authorizeUrl` and retains `verifier` and `state` to + * validate the callback and exchange the code. + * + * @param config - Authorization code config (with `authUrl`, `clientId`). + * @param options - Optional fetch override, abort signal, and overrides for + * `redirectUri`, `state`, and `verifier` (the latter two for deterministic testing). + * @returns The {@link AuthorizationRequest} (authorize URL + PKCE material). + * @throws {@link CCIPError} (CANTON_AUTH_ERROR) on discovery failure or invalid config. + * + * @example + * ```ts + * const req = await buildAuthorizationRequest({ + * type: 'authorizationCode', + * authUrl: 'https://auth.example.com', + * clientId: 'ccip-app', + * callbackUrl: 'http://localhost:8400/callback', + * }) + * // Redirect the user to req.authorizeUrl; keep req.verifier + req.state. + * ``` + */ +export async function buildAuthorizationRequest( + config: AuthorizationCodeAuthConfig, + options?: AuthorizationCodeProtocolOptions & { + /** Override the PKCE state parameter (for deterministic testing). */ + stateOverride?: string + /** Override the PKCE code verifier (for deterministic testing). */ + verifierOverride?: string + /** Override the redirect URI (takes precedence over `config.callbackUrl`). */ + redirectUri?: string + }, +): Promise { + const cfg = await resolveAuthorizationCodeConfig(config, options) + const redirectUri = options?.redirectUri ?? cfg.redirectUri + if (!redirectUri) { + throw new CCIPError( + CCIPErrorCode.CANTON_AUTH_ERROR, + 'authorizationCode auth requires a callbackUrl (redirect URI)', + ) + } + const state = options?.stateOverride ?? generateState() + const verifier = options?.verifierOverride ?? generateCodeVerifier() + const codeChallenge = await codeChallengeFromVerifier(verifier) + + const params = new URLSearchParams({ + client_id: cfg.client.client_id, + response_type: 'code', + scope: cfg.scopes.join(' '), + redirect_uri: redirectUri, + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }) + if (cfg.audience) { + params.set('audience', cfg.audience) + } + const authorizeUrl = `${cfg.as.authorization_endpoint}?${params.toString()}` + + return { authorizeUrl, verifier, codeChallenge, state, redirectUri } +} + +/** + * Validate the authorization callback URL and extract the code + state. + * + * Checks for an OAuth2 error redirect first (e.g. the user denied consent), + * then validates the state parameter (CSRF protection) and the presence of a + * code via `oauth4webapi.validateAuthResponse`. + * + * @param config - Authorization code config (with `authUrl`, `clientId`). + * @param callbackUrl - The full redirect URL the browser was sent back to + * (including `?code=…&state=…` or `?error=…`). + * @param expectedState - The `state` value from {@link buildAuthorizationRequest}. + * @param options - Optional fetch override and abort signal. + * @returns The validated {@link ValidatedCallback} (code + state). + * @throws {@link CCIPError} (CANTON_AUTH_ERROR) on an OAuth2 error redirect, + * state mismatch, or missing code. + * + * @example + * ```ts + * const { code } = await validateAuthorizationCallback( + * config, 'http://localhost:8400/callback?code=abc&state=xyz', req.state, + * ) + * ``` + */ +export async function validateAuthorizationCallback( + config: AuthorizationCodeAuthConfig, + callbackUrl: string | URL, + expectedState: string, + options?: AuthorizationCodeProtocolOptions, +): Promise { + const cfg = await resolveAuthorizationCodeConfig(config, options) + const reqUrl = callbackUrl instanceof URL ? callbackUrl : new URL(callbackUrl) + + // Always validate state + PKCE first (security-critical). The OAuth error + // redirect check below is purely informational — it must not run before or + // bypass the state validation, so we perform validation unconditionally. + let callbackParams: URLSearchParams + try { + callbackParams = oauth.validateAuthResponse(cfg.as, cfg.client, reqUrl, expectedState) + } catch (e) { + // If the authorization server redirected with an OAuth2 error (RFC 6749 + // §4.1.2.1, e.g. the user denied consent), surface it as a descriptive + // error instead of the generic state-mismatch message. + const oauthError = reqUrl.searchParams.get('error') + if (oauthError) { + const desc = reqUrl.searchParams.get('error_description') ?? oauthError + throw new CCIPError(CCIPErrorCode.CANTON_AUTH_ERROR, `Authorization failed: ${desc}`, { + context: { oauthError }, + }) + } + throw wrapOAuthError( + e, + 'Authorization callback validation failed (state mismatch or missing code)', + ) + } + const code = callbackParams.get('code') + if (!code) { + throw new CCIPError( + CCIPErrorCode.CANTON_AUTH_ERROR, + 'Authorization callback is missing the code parameter', + ) + } + return { code, state: expectedState, callbackParams } +} + +/** + * Exchange an authorization code for tokens (access + refresh). + * + * Performs the token endpoint request with the PKCE verifier (RFC 7636) and + * returns the parsed {@link AccessToken}. The embedder should persist the + * `refreshToken` so {@link refreshAuthorizationCodeToken} can renew the + * access token without re-running the interactive flow. + * + * @param config - Authorization code config (with `authUrl`, `clientId`). + * @param callback - The {@link ValidatedCallback} from + * {@link validateAuthorizationCallback} (carries the branded `callbackParams`). + * @param verifier - The PKCE code verifier from {@link buildAuthorizationRequest}. + * @param redirectUri - The redirect URI used in the authorize request. + * @param options - Optional fetch override and abort signal. + * @returns The obtained {@link AccessToken} (with `refreshToken` when issued). + * @throws {@link CCIPError} (CANTON_AUTH_ERROR) on token exchange failure. + * + * @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 + */ +export async function exchangeAuthorizationCode( + config: AuthorizationCodeAuthConfig, + callback: ValidatedCallback, + verifier: string, + redirectUri: string, + options?: AuthorizationCodeProtocolOptions, +): Promise { + const cfg = await resolveAuthorizationCodeConfig(config, options) + try { + const response = await oauth.authorizationCodeGrantRequest( + cfg.as, + cfg.client, + oauth.None(), + callback.callbackParams, + redirectUri, + verifier, + buildOAuthRequestOptions(cfg), + ) + const tokenResponse = await oauth.processAuthorizationCodeResponse(cfg.as, cfg.client, response) + return toAccessToken(tokenResponse) + } catch (e) { + throw wrapOAuthError(e, 'Authorization code token exchange failed') + } +} + +/** + * Refresh an access token using the `refresh_token` grant (RFC 6749 §6). + * + * @param config - Authorization code config (with `authUrl`, `clientId`). + * @param refreshToken - The refresh token from a prior {@link exchangeAuthorizationCode}. + * @param options - Optional fetch override and abort signal. + * @returns A fresh {@link AccessToken} (with a new `refreshToken` when rotated). + * @throws {@link CCIPError} (CANTON_AUTH_ERROR) on refresh failure. + * + * @see https://datatracker.ietf.org/doc/html/rfc6749#section-6 + */ +export async function refreshAuthorizationCodeToken( + config: AuthorizationCodeAuthConfig, + refreshToken: string, + options?: AuthorizationCodeProtocolOptions, +): Promise { + const cfg = await resolveAuthorizationCodeConfig(config, options) + try { + const response = await oauth.refreshTokenGrantRequest( + cfg.as, + cfg.client, + oauth.None(), + refreshToken, + buildOAuthRequestOptions(cfg), + ) + const tokenResponse = await oauth.processRefreshTokenResponse(cfg.as, cfg.client, response) + return toAccessToken(tokenResponse) + } catch (e) { + throw wrapOAuthError(e, 'Authorization code token refresh failed') + } +} + +/** + * An {@link AuthProvider} backed by caller-supplied token-fetch and + * token-refresh callbacks. + * + * The embedder implements the interactive flow (browser + callback) using + * {@link buildAuthorizationRequest}, {@link validateAuthorizationCallback}, + * and {@link exchangeAuthorizationCode}, then wraps the resulting token in + * this provider. Refresh uses {@link refreshAuthorizationCodeToken} when a + * refresh token is available; otherwise the initial token is re-fetched + * via the `fetchToken` callback. + * + * The SDK stays runtime-agnostic: this provider holds only the caching/refresh + * plumbing an embedder would otherwise have to re-implement. + */ +export class AuthorizationCodeProvider implements AuthProvider { + readonly type = 'authorizationCode' as const + private readonly fetchToken: () => Promise + private readonly cfg: AuthorizationCodeAuthConfig + private readonly options: AuthorizationCodeProtocolOptions | undefined + /** The last-seen token; updated on each fetch/refresh so doRefresh can read its refreshToken. */ + private lastToken: AccessToken | undefined + + /** + * Creates a provider from an initial token and callbacks. + * + * @param config - Authorization code config (used for refresh). + * @param initialToken - The token obtained from {@link exchangeAuthorizationCode}. + * @param fetchToken - Called when the cached token is expired and no refresh + * callback is supplied (or refresh fails). Must return a fresh token. + * @param options - Optional fetch override and abort signal (threaded to refresh). + */ + constructor( + config: AuthorizationCodeAuthConfig, + initialToken: AccessToken, + fetchToken: () => Promise, + options?: AuthorizationCodeProtocolOptions, + ) { + this.cfg = config + this.options = options + this.lastToken = initialToken + this.fetchToken = createMemoizedTokenFetcher(() => this.doRefresh(fetchToken), initialToken) + } + + /** Returns a valid access token, refreshing or re-fetching as needed. */ + token(): Promise { + return this.fetchToken() + } + + /** + * Refresh via the `refresh_token` grant when a refresh token is available; + * otherwise fall back to the caller-supplied `fetchToken` callback. + */ + private async doRefresh(fetchToken: () => Promise): Promise { + if (this.lastToken?.refreshToken) { + try { + const refreshed = await refreshAuthorizationCodeToken( + this.cfg, + this.lastToken.refreshToken, + this.options, + ) + this.lastToken = refreshed + return refreshed + } catch { + // refresh failed — fall through to the caller-supplied fetcher + } + } + const fetched = await fetchToken() + this.lastToken = fetched + return fetched + } +} + +/** + * Create an {@link AuthorizationCodeProvider} from an initial token and a + * re-fetch callback. + * + * Convenience wrapper around the {@link AuthorizationCodeProvider} constructor. + * The embedder is responsible for obtaining `initialToken` via the protocol + * helpers ({@link buildAuthorizationRequest} → {@link validateAuthorizationCallback} + * → {@link exchangeAuthorizationCode}). + * + * @param config - Authorization code config (used for refresh). + * @param initialToken - The token obtained from the interactive flow. + * @param fetchToken - Called when the cached token expires and refresh fails. + * @param options - Optional fetch override and abort signal. + * @returns A {@link AuthorizationCodeProvider}. + */ +export function createAuthorizationCodeProvider( + config: AuthorizationCodeAuthConfig, + initialToken: AccessToken, + fetchToken: () => Promise, + options?: AuthorizationCodeProtocolOptions, +): AuthorizationCodeProvider { + return new AuthorizationCodeProvider(config, initialToken, fetchToken, options) +} diff --git a/ccip-sdk/src/canton/authentication/client-credentials.ts b/ccip-sdk/src/canton/authentication/client-credentials.ts new file mode 100644 index 000000000..a4df5f947 --- /dev/null +++ b/ccip-sdk/src/canton/authentication/client-credentials.ts @@ -0,0 +1,218 @@ +import * as oauth from 'oauth4webapi' + +import { CCIPError, CCIPErrorCode } from '../../errors/index.ts' +import { discoverAuthorizationServer } from './metadata.ts' +import { + type OAuthRequestOptions, + buildOAuthRequestOptions, + createMemoizedTokenFetcher, + toAccessToken, + wrapOAuthError, +} from './token-source.ts' +import type { AccessToken, AuthProvider, ClientCredentialsAuthConfig } from './types.ts' + +/** + * OAuth2 client credentials flow authentication provider (RFC 6749 §4.4). + * + * @packageDocumentation + * + * Designed for machine-to-machine authentication where the client can securely + * maintain a client secret — ideal for CI/CD pipelines and server-to-server + * communication. Tokens are fetched on demand via `oauth4webapi` and cached + * until expiry; refresh is automatic (a new `client_credentials` grant request). + * + * Implements RFC 8414 metadata discovery and the Auth0-specific + * `audience` extension. + * + * @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.4 + * @see https://datatracker.ietf.org/doc/html/rfc8414 + */ + +/** Default scopes for the client credentials flow (Canton ledger API access). */ +const DEFAULT_CLIENT_CREDENTIALS_SCOPES = ['daml_ledger_api'] + +/** + * Resolved client-credentials provider configuration (after applying defaults). + */ +interface ResolvedClientCredentialsConfig { + as: oauth.AuthorizationServer + client: oauth.Client + clientSecret: string + scopes: string[] + audience: string + fetch?: typeof fetch + signal?: AbortSignal + allowInsecureRequests?: boolean +} + +/** Options for {@link ClientCredentialsProvider.fromDiscovery} and {@link ClientCredentialsProvider.fromDirect}. */ +export type ClientCredentialsProviderOptions = OAuthRequestOptions + +/** + * Client credentials auth provider. + * + * Uses a memoized token fetcher (`micro-memoize` with `{ async: true, expires }`) + * so the first `token()` call fetches a token and subsequent calls return the + * cached value until it expires. + */ +export class ClientCredentialsProvider implements AuthProvider { + readonly type = 'clientCredentials' as const + private readonly cfg: ResolvedClientCredentialsConfig + private readonly fetchToken: () => Promise + + /** Creates a provider from resolved config (internal — use fromDiscovery). */ + private constructor(cfg: ResolvedClientCredentialsConfig) { + this.cfg = cfg + this.fetchToken = createMemoizedTokenFetcher(() => this.doFetch()) + } + + /** Returns a valid access token, fetching via the client credentials grant if needed. */ + token(): Promise { + return this.fetchToken() + } + + /** Fetch a fresh access token via the client credentials grant. */ + private async doFetch(): Promise { + const params = new URLSearchParams() + if (this.cfg.scopes.length > 0) { + params.set('scope', this.cfg.scopes.join(' ')) + } + if (this.cfg.audience) { + params.set('audience', this.cfg.audience) + } + + try { + const response = await oauth.clientCredentialsGrantRequest( + this.cfg.as, + this.cfg.client, + oauth.ClientSecretPost(this.cfg.clientSecret), + params, + buildOAuthRequestOptions(this.cfg), + ) + const tokenResponse = await oauth.processClientCredentialsResponse( + this.cfg.as, + this.cfg.client, + response, + ) + return toAccessToken(tokenResponse) + } catch (e) { + throw wrapOAuthError(e, 'Client credentials token request failed') + } + } + + /** + * Create a provider using OAuth2 Authorization Server Metadata discovery + * (RFC 8414) to automatically locate the token endpoint. + * + * This is the recommended approach when the authorization server supports + * metadata discovery, as it eliminates the need to manually specify the + * token endpoint URL. + * + * @param config - Client credentials config with `authUrl` (authorization server base URL). + * @param options - Optional fetch override and abort signal. + * @returns A {@link ClientCredentialsProvider}. + * @throws {@link CCIPError} (CANTON_AUTH_ERROR) on discovery failure or invalid config. + * + * @see https://datatracker.ietf.org/doc/html/rfc8414 + */ + static async fromDiscovery( + config: ClientCredentialsAuthConfig, + options?: ClientCredentialsProviderOptions, + ): Promise { + validateClientCredentialsConfig(config) + const as = await discoverAuthorizationServer(config.authUrl, { + fetch: options?.fetch, + signal: options?.signal, + allowInsecureRequests: options?.allowInsecureRequests, + }) + if (!as.token_endpoint) { + throw new CCIPError( + CCIPErrorCode.CANTON_AUTH_ERROR, + 'Authorization server metadata is missing a token_endpoint', + ) + } + const resolved: ResolvedClientCredentialsConfig = { + as, + client: { client_id: config.clientId }, + clientSecret: config.clientSecret, + scopes: config.scopes?.length ? config.scopes : DEFAULT_CLIENT_CREDENTIALS_SCOPES, + audience: config.audience ?? '', + fetch: options?.fetch, + signal: options?.signal, + allowInsecureRequests: options?.allowInsecureRequests, + } + return new ClientCredentialsProvider(resolved) + } + + /** + * Create a provider with an explicit token endpoint URL (no discovery). + * + * Suitable for environments where the token endpoint is known in advance. + * A minimal `oauth4webapi.AuthorizationServer` is constructed from the + * provided `authUrl` (issuer) and `tokenUrl`. + * + * @param config - Client credentials config plus a `tokenUrl`. + * @param options - Optional fetch override and abort signal. + * @returns A {@link ClientCredentialsProvider}. + * @throws {@link CCIPError} (CANTON_AUTH_ERROR) on invalid config. + */ + static fromDirect( + config: ClientCredentialsAuthConfig & { tokenUrl: string }, + options?: ClientCredentialsProviderOptions, + ): ClientCredentialsProvider { + validateClientCredentialsConfig(config) + if (!config.tokenUrl) { + throw new CCIPError(CCIPErrorCode.CANTON_AUTH_ERROR, 'tokenUrl cannot be empty') + } + const resolved: ResolvedClientCredentialsConfig = { + as: { issuer: config.authUrl.replace(/\/$/, ''), token_endpoint: config.tokenUrl }, + client: { client_id: config.clientId }, + clientSecret: config.clientSecret, + scopes: config.scopes?.length ? config.scopes : DEFAULT_CLIENT_CREDENTIALS_SCOPES, + audience: config.audience ?? '', + fetch: options?.fetch, + signal: options?.signal, + allowInsecureRequests: options?.allowInsecureRequests, + } + return new ClientCredentialsProvider(resolved) + } +} + +/** + * Validate the shared client-credentials config fields. + */ +function validateClientCredentialsConfig(config: ClientCredentialsAuthConfig): void { + if (!config.authUrl.trim()) { + throw new CCIPError( + CCIPErrorCode.CANTON_AUTH_ERROR, + 'clientCredentials auth requires a non-empty authUrl', + ) + } + if (!config.clientId.trim()) { + throw new CCIPError( + CCIPErrorCode.CANTON_AUTH_ERROR, + 'clientCredentials auth requires a non-empty clientId', + ) + } + if (!config.clientSecret.trim()) { + throw new CCIPError( + CCIPErrorCode.CANTON_AUTH_ERROR, + 'clientCredentials auth requires a non-empty clientSecret', + ) + } +} + +/** + * Create a client credentials provider using RFC 8414 metadata discovery. + * + * Convenience wrapper for {@link ClientCredentialsProvider.fromDiscovery}. + * + * @param config - Client credentials config. + * @param options - Optional fetch override and abort signal. + */ +export function createClientCredentialsProvider( + config: ClientCredentialsAuthConfig, + options?: ClientCredentialsProviderOptions, +): Promise { + return ClientCredentialsProvider.fromDiscovery(config, options) +} diff --git a/ccip-sdk/src/canton/authentication/index.ts b/ccip-sdk/src/canton/authentication/index.ts new file mode 100644 index 000000000..7f8736639 --- /dev/null +++ b/ccip-sdk/src/canton/authentication/index.ts @@ -0,0 +1,188 @@ +/** + * Canton authentication providers — public exports. + * + * @packageDocumentation + * + * OAuth 2.0 authentication for the Canton Ledger API. + * + * Three auth schemes are supported: + * - **static** — pre-obtained JWT (no refresh) + * - **clientCredentials** — OAuth2 client credentials grant (machine-to-machine) + * - **authorizationCode** — OAuth2 authorization code + PKCE (interactive browser login) + * + * The SDK exports only **runtime-agnostic** protocol pieces: types, RFC 8414 + * metadata discovery, PKCE/token-source primitives, the `clientCredentials` + * and `static` providers, and the `authorizationCode` **protocol helpers** + * (build-authorize-URL, callback validation, code→token exchange, refresh + * grant). No `node:*` modules are imported here — the environment-specific + * orchestration (local callback server, browser `open`, env-var resolution) + * lives in the CLI (`providers/canton/`). + * + * Use {@link createAuthProvider} to build a provider from a discriminated + * {@link AuthConfig} (for `static` / `clientCredentials`), or compose the + * `authorizationCode` protocol helpers with your own callback handling and + * wrap the result in {@link createAuthorizationCodeProvider}. + * + * @example + * ```ts + * import { createAuthProvider, AuthType } from '@chainlink/ccip-sdk' + * + * // Client credentials (CI/CD) + * const provider = await createAuthProvider({ + * type: AuthType.ClientCredentials, + * authUrl: 'https://auth.example.com', + * clientId: 'my-client-id', + * clientSecret: 'my-client-secret', + * }) + * const jwt = (await provider.token()).accessToken + * ``` + * + * @example + * ```ts + * // Authorization code (interactive browser login) — protocol pieces only. + * // The embedder (CLI / web app) owns the callback server + browser opening. + * import { + * buildAuthorizationRequest, + * validateAuthorizationCallback, + * exchangeAuthorizationCode, + * createAuthorizationCodeProvider, + * } from '@chainlink/ccip-sdk' + * + * const req = await buildAuthorizationRequest({ + * type: 'authorizationCode', + * authUrl: 'https://auth.example.com', + * clientId: 'ccip-app', + * callbackUrl: 'http://localhost:8400/callback', + * }) + * // …redirect user to req.authorizeUrl, receive callback at req.redirectUri… + * const { code } = await validateAuthorizationCallback(config, callbackUrl, req.state) + * const token = await exchangeAuthorizationCode(config, code, req.verifier, req.redirectUri) + * const provider = createAuthorizationCodeProvider(config, token, () => runFlowAgain()) + * ``` + */ + +import { CCIPError, CCIPErrorCode } from '../../errors/index.ts' +import type { AuthorizationCodeProvider } from './authorization-code.ts' +import { + type ClientCredentialsProvider, + createClientCredentialsProvider, +} from './client-credentials.ts' +import { type StaticProvider, createStaticProvider } from './static.ts' +import type { OAuthRequestOptions } from './token-source.ts' +import { type AuthConfig, type AuthProvider, AuthType } from './types.ts' + +export { + type AuthorizationCodeProtocolOptions, + type AuthorizationRequest, + type ValidatedCallback, + AuthorizationCodeProvider, + buildAuthorizationRequest, + createAuthorizationCodeProvider, + exchangeAuthorizationCode, + refreshAuthorizationCodeToken, + resolveAuthorizationCodeConfig, + validateAuthorizationCallback, +} from './authorization-code.ts' +export { ClientCredentialsProvider, createClientCredentialsProvider } from './client-credentials.ts' +export { + type AuthorizationServer, + type AuthorizationServerMetadata, + discoverAuthorizationServer, + getAuthorizationServerMetadata, +} from './metadata.ts' +export { + type OAuthRequestOptions, + buildOAuthRequestOptions, + codeChallengeFromVerifier, + createMemoizedTokenFetcher, + generateCodeVerifier, + generateState, + isTokenExpired, + toAccessToken, + wrapOAuthError, +} from './token-source.ts' +export { StaticProvider, createStaticProvider } from './static.ts' +export { + type AccessToken, + type AuthConfig, + type AuthProvider, + type AuthorizationCodeAuthConfig, + type ClientCredentialsAuthConfig, + type StaticAuthConfig, + AuthType, + isAccessToken, +} from './types.ts' + +/** + * Options for {@link createAuthProvider}. + */ +export type AuthProviderOptions = OAuthRequestOptions + +/** + * Build an {@link AuthProvider} from a discriminated {@link AuthConfig}. + * + * Supports the `static` and `clientCredentials` schemes — both are fully + * runtime-agnostic (pure `fetch` + WebCrypto). The `authorizationCode` scheme + * is **not** handled here because it requires environment-specific + * orchestration (callback server, browser); use the protocol helpers exported + * from this package ({@link buildAuthorizationRequest}, + * {@link validateAuthorizationCallback}, {@link exchangeAuthorizationCode}, + * {@link createAuthorizationCodeProvider}) to compose it with your own + * callback handling. + * + * The `type` field selects the auth scheme; when omitted, `"static"` + * is assumed (backward compatible with the existing `CantonConfig.jwt` field). + * + * @param config - Auth config (static or clientCredentials). + * @param options - Optional fetch override and abort signal. + * @returns An {@link AuthProvider} whose `token()` yields valid JWTs. + * @throws {@link CCIPError} (CANTON_AUTH_ERROR) on invalid config or auth failure. + * + * @example + * ```ts + * const provider = await createAuthProvider({ + * type: AuthType.ClientCredentials, + * authUrl: 'https://smartcontract.okta.com/oauth2/austsuml9q2WhPBMM5d7', + * clientId: 'my-client-id', + * clientSecret: 'my-client-secret', + * }) + * ``` + */ +export async function createAuthProvider( + config: AuthConfig, + options?: AuthProviderOptions, +): Promise { + const type: AuthType = config.type ?? AuthType.Static + + switch (type) { + case AuthType.Static: + return createStaticProvider((config as { jwt?: string }).jwt ?? '') + + case AuthType.ClientCredentials: + return createClientCredentialsProvider( + config as Parameters[0], + options, + ) + + case AuthType.AuthorizationCode: + throw new CCIPError( + CCIPErrorCode.CANTON_AUTH_ERROR, + 'authorizationCode cannot be built via createAuthProvider — it requires environment-specific ' + + 'orchestration (callback server, browser). Use buildAuthorizationRequest + ' + + 'validateAuthorizationCallback + exchangeAuthorizationCode + createAuthorizationCodeProvider.', + ) + + default: { + const t: string = type + throw new CCIPError( + CCIPErrorCode.CANTON_AUTH_ERROR, + `Unsupported auth type: "${t}" (expected static, clientCredentials, or authorizationCode)`, + ) + } + } +} + +/** + * Type alias for the union of concrete provider classes. + */ +export type AnyAuthProvider = StaticProvider | ClientCredentialsProvider | AuthorizationCodeProvider diff --git a/ccip-sdk/src/canton/authentication/metadata.ts b/ccip-sdk/src/canton/authentication/metadata.ts new file mode 100644 index 000000000..69567872e --- /dev/null +++ b/ccip-sdk/src/canton/authentication/metadata.ts @@ -0,0 +1,116 @@ +import * as oauth from 'oauth4webapi' + +import { CCIPError, CCIPErrorCode } from '../../errors/index.ts' + +/** + * A subset of the OAuth 2.0 Authorization Server Metadata (RFC 8414, §2). + * + * This is a thin camelCase wrapper over the `oauth4webapi.AuthorizationServer` + * type, kept for backward compatibility with the existing public API. + * + * @see https://datatracker.ietf.org/doc/html/rfc8414#section-2 + */ +export interface AuthorizationServerMetadata { + /** The authorization server's issuer identifier URL. */ + issuer: string + /** URL of the authorization server's authorization endpoint. */ + authorizationEndpoint: string + /** URL of the authorization server's token endpoint. */ + tokenEndpoint: string + /** + * PKCE code challenge methods supported by this authorization server. + * Omitted when the server does not support PKCE. + * + * @see https://datatracker.ietf.org/doc/html/rfc7636#section-4.2 + */ + codeChallengeMethodsSupported?: string[] +} + +/** Re-export the raw `oauth4webapi.AuthorizationServer` for provider implementations. */ +export type { AuthorizationServer } from 'oauth4webapi' + +/** + * Convert an `oauth4webapi.AuthorizationServer` to our camelCase wrapper. + */ +function toMetadata(as: oauth.AuthorizationServer): AuthorizationServerMetadata { + return { + issuer: as.issuer, + authorizationEndpoint: as.authorization_endpoint ?? '', + tokenEndpoint: as.token_endpoint ?? '', + codeChallengeMethodsSupported: as.code_challenge_methods_supported, + } +} + +/** + * Parse a string into a URL, throwing a `CCIPError` on failure. + */ +function parseIssuerUrl(baseUrl: string): URL { + try { + return new URL(baseUrl) + } catch { + throw new CCIPError( + CCIPErrorCode.CANTON_AUTH_ERROR, + `Authorization server URL is not a valid URL: "${baseUrl}"`, + ) + } +} + +/** + * Fetch OAuth 2.0 Authorization Server Metadata from the well-known endpoint. + * + * Uses `oauth4webapi.discoveryRequest` + `processDiscoveryResponse` (RFC 8414), + * which validates the issuer and parses the metadata. The result is mapped to + * our camelCase {@link AuthorizationServerMetadata} interface. + * + * @param authorizationServerURL - Base URL of the authorization server (trailing `/` stripped). + * @param options - Optional fetch override and abort signal. + * @returns The parsed authorization server metadata. + * @throws {@link CCIPError} with {@link CCIPErrorCode.CANTON_AUTH_ERROR} on discovery failure. + * + * @see https://datatracker.ietf.org/doc/html/rfc8414 + */ +export async function getAuthorizationServerMetadata( + authorizationServerURL: string, + options?: { fetch?: typeof fetch; signal?: AbortSignal; allowInsecureRequests?: boolean }, +): Promise { + const as = await discoverAuthorizationServer(authorizationServerURL, options) + return toMetadata(as) +} + +/** + * Perform RFC 8414 discovery and return the raw `oauth4webapi.AuthorizationServer`. + * + * Used internally by the client-credentials and authorization-code providers + * that need to pass the `AuthorizationServer` directly to `oauth4webapi` grant + * request functions. + */ +export async function discoverAuthorizationServer( + authorizationServerURL: string, + options?: { fetch?: typeof fetch; signal?: AbortSignal; allowInsecureRequests?: boolean }, +): Promise { + const baseUrl = authorizationServerURL.replace(/\/$/, '') + if (!baseUrl) { + throw new CCIPError(CCIPErrorCode.CANTON_AUTH_ERROR, 'Authorization server URL cannot be empty') + } + + const issuerUrl = parseIssuerUrl(baseUrl) + + try { + const response = await oauth.discoveryRequest(issuerUrl, { + algorithm: 'oauth2', + [oauth.customFetch]: options?.fetch, + signal: options?.signal, + [oauth.allowInsecureRequests]: options?.allowInsecureRequests, + }) + return await oauth.processDiscoveryResponse(issuerUrl, response) + } catch (e) { + if (e instanceof CCIPError) throw e + throw new CCIPError( + CCIPErrorCode.CANTON_AUTH_ERROR, + `Failed to discover authorization server metadata from ${baseUrl}: ${ + e instanceof Error ? e.message : String(e) + }`, + { cause: e instanceof Error ? e : undefined, isTransient: true }, + ) + } +} diff --git a/ccip-sdk/src/canton/authentication/static.ts b/ccip-sdk/src/canton/authentication/static.ts new file mode 100644 index 000000000..f39f29801 --- /dev/null +++ b/ccip-sdk/src/canton/authentication/static.ts @@ -0,0 +1,56 @@ +import { CCIPError, CCIPErrorCode } from '../../errors/index.ts' +import { type AccessToken, type AuthProvider, AuthType } from './types.ts' + +/** + * Static authentication provider. + * + * @packageDocumentation + * + * Wraps a pre-obtained JWT (no refresh). The `token()` method is a plain async + * function that always returns the same JWT. + */ + +/** + * Base class for static JWT providers. + * + * Delegates {@link token} to a simple async function that always yields the + * configured JWT. + */ +abstract class StaticProviderBase implements AuthProvider { + abstract readonly type: AuthType + private readonly tokenValue: AccessToken + + /** Creates a new static provider wrapping the given JWT. */ + constructor(jwt: string) { + if (!jwt || !jwt.trim()) { + throw new CCIPError( + CCIPErrorCode.CANTON_AUTH_ERROR, + `${this.constructor.name} requires a non-empty JWT token`, + ) + } + this.tokenValue = { accessToken: jwt.trim() } + } + + /** Returns the static JWT. */ + token(): Promise { + return Promise.resolve(this.tokenValue) + } +} + +/** + * Static auth provider — pre-obtained JWT. Use for Canton participant + * endpoints where you already hold a valid JWT. + */ +export class StaticProvider extends StaticProviderBase { + readonly type: AuthType = AuthType.Static +} + +/** + * Create a static auth provider. + * + * @param jwt - Pre-obtained JWT token. + * @returns A {@link StaticProvider}. + */ +export function createStaticProvider(jwt: string): StaticProvider { + return new StaticProvider(jwt) +} diff --git a/ccip-sdk/src/canton/authentication/token-source.ts b/ccip-sdk/src/canton/authentication/token-source.ts new file mode 100644 index 000000000..27e265195 --- /dev/null +++ b/ccip-sdk/src/canton/authentication/token-source.ts @@ -0,0 +1,183 @@ +import { memoize } from 'micro-memoize' +import * as oauth from 'oauth4webapi' + +import { CCIPError, CCIPErrorCode } from '../../errors/index.ts' +import type { AccessToken } from './types.ts' + +/** + * Shared token-source primitives for the Canton authentication providers. + * + * @packageDocumentation + * + * PKCE helpers and token-response conversion delegate to `oauth4webapi` (the + * spec-compliant OAuth2/OIDC library for JavaScript runtimes). Token caching + * with concurrent fetch coalescing and expiry-based invalidation is handled by + * `micro-memoize` (`{ async: true }`). + */ + +/** Skew applied to token expiry so refresh happens slightly before the real expiry. */ +const EXPIRY_SKEW_MS = 10_000 + +/** + * Returns `true` when `token` is missing or expired (accounting for skew). + */ +export function isTokenExpired(token: AccessToken | undefined): boolean { + if (!token) return true + if (token.expiresAt === undefined) return false // no expiry → assume valid + return Date.now() >= token.expiresAt - EXPIRY_SKEW_MS +} + +// --------------------------------------------------------------------------- +// PKCE / state helpers — delegate to oauth4webapi (Web Crypto API, cross-runtime) +// --------------------------------------------------------------------------- + +/** Cryptographically-secure random PKCE code verifier (RFC 7636 §4.1). */ +export function generateCodeVerifier(): string { + return oauth.generateRandomCodeVerifier() +} + +/** S256 code challenge = base64url( sha256( verifier ) ). Async (Web Crypto API). */ +export function codeChallengeFromVerifier(verifier: string): Promise { + return oauth.calculatePKCECodeChallenge(verifier) +} + +/** Cryptographically-secure random state parameter (CSRF protection). */ +export function generateState(): string { + return oauth.generateRandomState() +} + +// --------------------------------------------------------------------------- +// Token response conversion +// --------------------------------------------------------------------------- + +/** + * Convert an `oauth4webapi.TokenEndpointResponse` to our {@link AccessToken}. + * + * The `expiresAt` field is derived from `expires_in` (seconds) so callers can + * check staleness without re-parsing the JWT. + */ +export function toAccessToken(response: oauth.TokenEndpointResponse): AccessToken { + return { + accessToken: response.access_token, + tokenType: response.token_type, + expiresAt: + typeof response.expires_in === 'number' ? Date.now() + response.expires_in * 1000 : undefined, + refreshToken: response.refresh_token, + } +} + +/** + * Wrap an `oauth4webapi` OAuth2Error (thrown by `process*Response`) in a + * {@link CCIPError} with `CANTON_AUTH_ERROR`. + */ +export function wrapOAuthError(e: unknown, context?: string): CCIPError { + if (e instanceof CCIPError) return e + const message = e instanceof Error ? e.message : String(e) + return new CCIPError( + CCIPErrorCode.CANTON_AUTH_ERROR, + context ? `${context}: ${message}` : message, + { cause: e instanceof Error ? e : undefined }, + ) +} + +/** + * Shared options for `oauth4webapi` HTTP requests (custom fetch + insecure requests). + * + * Used by {@link buildOAuthRequestOptions} to construct the symbol-keyed + * options object that `oauth4webapi` grant request functions expect. + */ +export interface OAuthRequestOptions { + /** Custom fetch implementation (testing / custom HTTP transport). */ + fetch?: typeof fetch + /** Abort signal for the HTTP request. */ + signal?: AbortSignal + /** + * Allow HTTP (non-HTTPS) requests. **For testing only** — never use in + * production. Passes through to `oauth4webapi`'s `allowInsecureRequests`. + */ + allowInsecureRequests?: boolean +} + +/** + * Build the `oauth4webapi` request options object from our {@link OAuthRequestOptions}. + * + * Shared by the client-credentials and authorization-code providers to avoid + * duplicating the symbol-keyed options construction. + */ +export function buildOAuthRequestOptions(opts: OAuthRequestOptions) { + return { + [oauth.customFetch]: opts.fetch, + signal: opts.signal, + [oauth.allowInsecureRequests]: opts.allowInsecureRequests, + } +} + +// --------------------------------------------------------------------------- +// Memoized token fetcher +// --------------------------------------------------------------------------- + +/** + * Create a memoized async token fetcher that caches the result until the token + * expires, coalesces concurrent callers onto a single in-flight promise, and + * auto-removes the cache entry on rejection. + * + * Uses `micro-memoize` with `{ async: true }` for promise coalescing and + * rejection-based cache invalidation. Token expiry is tracked via a + * `lastToken` closure variable; when expired, the cache is cleared so the + * next call re-fetches. + * + * @param fetcher - Called when the cached token is missing or expired. + * MUST return a fresh {@link AccessToken}. + * @param initial - Optional initial token (returned on the first call without + * fetching, when still valid). + * @returns A memoized `() => Promise` that caches until the + * returned token's `expiresAt` passes (accounting for skew). + */ +export function createMemoizedTokenFetcher( + fetcher: () => Promise, + initial?: AccessToken, +): () => Promise { + // Track the last-seen token outside the memoize cache so we can check expiry + // without awaiting the cached Promise (which would defeat coalescing). + let lastToken: AccessToken | undefined = initial + + const memoized = memoize( + async () => { + const token = await fetcher() + lastToken = token + return token + }, + { + // `async: true` coalesces concurrent callers onto a single in-flight + // promise and auto-removes the cache entry on rejection. + async: true, + }, + ) + + // When an initial token is provided and still valid, short-circuit the first + // call to return it without fetching. Subsequent calls delegate to the + // memoized fetcher (which will fetch only if the token has since expired). + if (initial && !isTokenExpired(initial)) { + let usedInitial = false + return async (): Promise => { + if (!usedInitial) { + usedInitial = true + return initial + } + if (isTokenExpired(lastToken)) { + memoized.cache.clear('token expired') + } + return memoized() + } + } + + return async (): Promise => { + // Only clear the cache when we have a previously-fetched token that has + // since expired. When lastToken is undefined (never fetched), skip clearing + // so concurrent first calls coalesce onto a single in-flight fetch. + if (lastToken && isTokenExpired(lastToken)) { + memoized.cache.clear('token expired') + } + return memoized() + } +} diff --git a/ccip-sdk/src/canton/authentication/types.ts b/ccip-sdk/src/canton/authentication/types.ts new file mode 100644 index 000000000..9dc05f7a6 --- /dev/null +++ b/ccip-sdk/src/canton/authentication/types.ts @@ -0,0 +1,154 @@ +/** + * Shared types for the Canton authentication providers. + * + * @packageDocumentation + * + * OAuth 2.0 authentication for the Canton Ledger API (JSON API / HTTP). + * + * Three auth schemes are supported: + * - `static` — a pre-obtained JWT + * - `clientCredentials`— OAuth2 client credentials grant (RFC 6749 §4.4, machine-to-machine) + * - `authorizationCode`— OAuth2 authorization code + PKCE (RFC 6749 §4.1 / RFC 7636, interactive browser login) + */ + +/** + * Supported authentication types for Canton participant APIs. + */ +export const AuthType = { + /** Pre-obtained JWT. */ + Static: 'static', + /** OAuth2 client credentials grant (machine-to-machine, CI/CD). */ + ClientCredentials: 'clientCredentials', + /** OAuth2 authorization code + PKCE (interactive browser login). */ + AuthorizationCode: 'authorizationCode', +} as const + +/** Union of supported auth type strings. */ +export type AuthType = (typeof AuthType)[keyof typeof AuthType] + +/** + * An OAuth 2.0 access token with optional refresh metadata. + * + * The `expiresAt` field is derived from `expires_in` (seconds) at fetch time + * so callers can check staleness without re-parsing the JWT. + */ +export interface AccessToken { + /** The bearer access token (JWT). */ + accessToken: string + /** Token type, typically `"Bearer"`. */ + tokenType?: string + /** Absolute expiry (epoch ms). `undefined` when the server did not return `expires_in`. */ + expiresAt?: number + /** Refresh token (authorization code flow only). Used to obtain new access tokens. */ + refreshToken?: string +} + +/** + * An authentication provider for the Canton Ledger API. + * + * Exposes the auth scheme (`type`) and a `token()` method that returns a valid + * bearer JWT, fetching or refreshing as needed. + */ +export interface AuthProvider { + /** The auth scheme this provider was built from. */ + readonly type: AuthType + /** + * Returns a valid (non-expired) access token, fetching or refreshing as needed. + * + * Safe to call concurrently. + */ + token(): Promise +} + +/** + * Base configuration shared by all auth schemes. + * + * Base configuration shared by all auth schemes. Each concrete provider + * accepts a subset of these fields. + */ +export interface AuthConfigBase { + /** Auth scheme selector. Defaults to `"static"` when omitted (backward compatible). */ + type?: AuthType + /** + * OAuth2 "audience" request parameter (Auth0-specific extension). + * + * Identifies the API the issued access token should target (its JWT `aud` claim). + * Only honored by Auth0 (or servers emulating Auth0); Okta/Keycloak ignore it. + * Applicable to `clientCredentials` and `authorizationCode` only. + */ + audience?: string +} + +/** + * `static` auth config. + */ +export interface StaticAuthConfig extends AuthConfigBase { + type?: typeof AuthType.Static + /** Pre-obtained JWT. Required. */ + jwt: string +} + +/** + * `clientCredentials` auth config (RFC 6749 §4.4). + */ +export interface ClientCredentialsAuthConfig extends AuthConfigBase { + type: typeof AuthType.ClientCredentials + /** OIDC authorization server base URL (e.g. `https://auth.example.com`). */ + authUrl: string + /** OAuth2 client identifier. */ + clientId: string + /** OAuth2 client secret (machine-to-machine). */ + clientSecret: string + /** OAuth2 scopes. Defaults to `["daml_ledger_api"]`. */ + scopes?: string[] +} + +/** + * `authorizationCode` auth config (RFC 6749 §4.1 + PKCE RFC 7636). + * + * This config describes the **protocol parameters** only. The + * environment-specific orchestration (local callback server, browser opening, + * flow timeout) is owned by the CLI / embedder, not the SDK. + */ +export interface AuthorizationCodeAuthConfig extends AuthConfigBase { + type: typeof AuthType.AuthorizationCode + /** OIDC authorization server base URL (e.g. `https://auth.example.com`). */ + authUrl: string + /** OAuth2 client identifier. */ + clientId: string + /** OAuth2 scopes. Defaults to `["openid", "daml_ledger_api"]`. */ + scopes?: string[] + /** + * Redirect URI the authorization server redirects back to. + * + * Required by the protocol helpers ({@link buildAuthorizationRequest}, + * {@link exchangeAuthorizationCode}); the embedder supplies the value that + * matches its callback handling (e.g. `http://localhost:8400/callback` for + * the CLI's local server). + */ + callbackUrl?: string +} + +/** + * Discriminated union of all auth configs. + * + * The `type` field discriminates between the three schemes. When omitted, + * `static` is assumed and `jwt` is required. + */ +export type AuthConfig = + | StaticAuthConfig + | ClientCredentialsAuthConfig + | AuthorizationCodeAuthConfig + +/** + * Type-guard for {@link AccessToken}. + */ +export function isAccessToken(v: unknown): v is AccessToken { + return ( + typeof v === 'object' && + v !== null && + 'accessToken' in v && + typeof (v as AccessToken).accessToken === 'string' && + (v as AccessToken).accessToken.length > 0 + ) +} diff --git a/ccip-sdk/src/canton/ccv-addresses.ts b/ccip-sdk/src/canton/ccv-addresses.ts index 8abb8f623..f698add95 100644 --- a/ccip-sdk/src/canton/ccv-addresses.ts +++ b/ccip-sdk/src/canton/ccv-addresses.ts @@ -22,7 +22,7 @@ export function decodeCantonVerifierDestAddress(destAddress: string): string { return trimmed } -/** InstanceAddress hex for CCV execute EDS lookups (mirrors Go `InstanceAddress()`). */ +/** InstanceAddress hex for CCV execute EDS lookups. */ export function resolveExecuteCcvAddress(verifierDestAddress: string): string { const raw = decodeCantonVerifierDestAddress(verifierDestAddress) if (raw.includes('@')) return `0x${hashedUtf8Hex(raw)}` diff --git a/ccip-sdk/src/canton/client/client.ts b/ccip-sdk/src/canton/client/client.ts index 3acc90251..78b94b7da 100644 --- a/ccip-sdk/src/canton/client/client.ts +++ b/ccip-sdk/src/canton/client/client.ts @@ -95,8 +95,16 @@ export type HashingSchemeVersion = NonNullable Promise` + * getter for a refreshable token (e.g. an OAuth2 caching provider). When a + * getter is supplied, each request awaits it and uses the returned JWT in the + * `Authorization` header, enabling automatic refresh without re-creating the + * client. + */ + jwt?: string | (() => Promise) /** Request timeout in milliseconds */ timeout?: number /** Abort signal for cancelling in-flight requests (e.g., from Chain.abort) */ @@ -114,7 +122,7 @@ export interface CantonClientConfig { */ export function createCantonClient(config: CantonClientConfig) { const baseUrl = config.baseUrl.replace(/\/$/, '') - const headers = buildHeaders(config.jwt) + const jwt = config.jwt const timeoutMs = config.timeout ?? 30_000 const signal = config.signal // Build a fetch adapter only when the caller explicitly supplies a fetch function. @@ -123,14 +131,24 @@ export function createCantonClient(config: CantonClientConfig) { ? createAxiosFetchAdapter(config.fetch, signal) : undefined - // Internal helpers that capture baseUrl/headers/timeoutMs/signal for + /** + * Resolve the request headers. When `jwt` is a function, await it per request + * so each call carries a fresh token (enabling automatic refresh). + */ + async function resolveHeaders(): Promise> { + const token = typeof jwt === 'function' ? await jwt() : jwt + return buildHeaders(token) + } + + // Internal helpers that capture baseUrl/timeoutMs/signal for // cleaner call sites inside createCantonClient. - const get2 = ( + const get2 = async ( path: string, queryParams?: Record, retries?: number, - ): Promise => - request( + ): Promise => { + const headers = await resolveHeaders() + return request( 'GET', baseUrl, path, @@ -142,15 +160,17 @@ export function createCantonClient(config: CantonClientConfig) { signal, fetchAdapter, ) + } - const post2 = ( + const post2 = async ( path: string, body: unknown, queryParams?: Record, retries?: number, overrideTimeoutMs?: number, - ): Promise => - request( + ): Promise => { + const headers = await resolveHeaders() + return request( 'POST', baseUrl, path, @@ -162,6 +182,7 @@ export function createCantonClient(config: CantonClientConfig) { signal, fetchAdapter, ) + } return { /** diff --git a/ccip-sdk/src/canton/defaults.ts b/ccip-sdk/src/canton/defaults.ts index a2ce742b4..e2595ccbb 100644 --- a/ccip-sdk/src/canton/defaults.ts +++ b/ccip-sdk/src/canton/defaults.ts @@ -18,7 +18,7 @@ export const DEFAULT_CANTON_SENDER_INSTANCE_ID = 'ccipsender' /** CCIP-owned LINK instrument id on Canton (`ccipParty::link-token`). */ export const DEFAULT_CANTON_LINK_INSTRUMENT_ID = 'link-token' -/** CLI / Go `profiles` fee-token names returned by {@link CantonChain.getFeeTokens}. */ +/** CLI `profiles` fee-token names returned by {@link CantonChain.getFeeTokens}. */ export const CANTON_FEE_TOKEN_CLI_SYMBOLS = { native: 'native', link: 'LINK', diff --git a/ccip-sdk/src/canton/events.ts b/ccip-sdk/src/canton/events.ts index 7ee03dd20..56093b8b3 100644 --- a/ccip-sdk/src/canton/events.ts +++ b/ccip-sdk/src/canton/events.ts @@ -340,7 +340,6 @@ export function extractEventsFromTransaction(obj: unknown): unknown[] { /** * Find the contract ID of a newly created template in a ledger transaction. - * Mirrors Go `extractCreatedReceiverCID` / `cantonops.extractCreatedReceiverCID`. */ export function extractCreatedContractId( transaction: unknown, diff --git a/ccip-sdk/src/canton/index.ts b/ccip-sdk/src/canton/index.ts index 8adaab2cf..0759b53d8 100644 --- a/ccip-sdk/src/canton/index.ts +++ b/ccip-sdk/src/canton/index.ts @@ -147,6 +147,42 @@ export { sumCantonHoldingAmounts, } from './defaults.ts' +// Authentication providers (OAuth 2.0: static, clientCredentials, authorizationCode protocol helpers) +export { + type AccessToken, + type AnyAuthProvider, + type AuthConfig, + type AuthProvider, + type AuthProviderOptions, + type AuthorizationCodeAuthConfig, + type AuthorizationCodeProtocolOptions, + type AuthorizationRequest, + type AuthorizationServerMetadata, + type ClientCredentialsAuthConfig, + type StaticAuthConfig, + type ValidatedCallback, + AuthType as CantonAuthType, + AuthorizationCodeProvider, + ClientCredentialsProvider, + StaticProvider, + buildAuthorizationRequest, + codeChallengeFromVerifier, + createAuthProvider, + createAuthorizationCodeProvider, + createClientCredentialsProvider, + createMemoizedTokenFetcher, + createStaticProvider, + exchangeAuthorizationCode, + generateCodeVerifier, + generateState, + getAuthorizationServerMetadata, + isAccessToken, + isTokenExpired, + refreshAuthorizationCodeToken, + resolveAuthorizationCodeConfig, + validateAuthorizationCallback, +} from './authentication/index.ts' + /** * Canton chain implementation supporting Canton Ledger networks. * @@ -379,7 +415,7 @@ export class CantonChain extends Chain { */ static async fromUrl(url: string, ctx?: ChainContext): Promise { // Check that ctx has the necessary cantonConfig - if (!ctx || !ctx.cantonConfig || typeof ctx.cantonConfig.jwt !== 'string') { + if (!ctx || !ctx.cantonConfig) { throw new CCIPError( CCIPErrorCode.METHOD_UNSUPPORTED, 'CantonChain.fromUrl: ctx.cantonConfig is required', @@ -393,10 +429,23 @@ export class CantonChain extends Chain { ) } + // Authentication: `jwt` is either a static string or a `() => Promise` + // getter for refreshable tokens. The SDK never orchestrates an OAuth flow — + // the caller (CLI / embedder) resolves auth upfront and hands the result to + // `cantonConfig.jwt`. Thread it through to every client so each request + // carries a fresh JWT when a getter is supplied. + const jwt = ctx.cantonConfig.jwt + if (!jwt) { + throw new CCIPError( + CCIPErrorCode.CANTON_AUTH_ERROR, + 'CantonChain.fromUrl: cantonConfig.jwt is required for authentication', + ) + } + const fetchFn = ctx.fetch const client = createCantonClient({ baseUrl: url, - jwt: ctx.cantonConfig.jwt, + jwt, signal: ctx.abort, fetch: fetchFn, }) @@ -421,16 +470,16 @@ export class CantonChain extends Chain { }) const transferInstructionClient = createTransferInstructionClient({ baseUrl: ctx.cantonConfig.transferInstructionUrl, - jwt: ctx.cantonConfig.jwt, + jwt, }) const linkTransferInstructionClient = createTransferInstructionClient({ baseUrl: ctx.cantonConfig.edsUrl, - jwt: ctx.cantonConfig.jwt, + jwt, useScanProxy: false, }) const tokenMetadataClient = createTokenMetadataClient({ baseUrl: ctx.cantonConfig.transferInstructionUrl, - jwt: ctx.cantonConfig.jwt, + jwt, }) return CantonChain.fromClient( client, @@ -1401,13 +1450,17 @@ export class CantonChain extends Chain { /** * Find or create a `CCIPReceiver` for execute, setting `requiredCCVs` from the - * indexer attestation (mirrors Go `GetOrCreateReceiver`). + * indexer attestation. + * + * When a {@link TransactionSigner} is supplied, contract creation/update uses + * the interactive submission path; otherwise it falls back to direct + * `submitAndWaitForTransaction` (JWT-authenticated). */ private async ensureReceiverForExecute( payer: string, finality: number, attestationCcvRaw: string | undefined, - signer: TransactionSigner | undefined, + signer?: TransactionSigner, hint?: string, ): Promise { const requiredCcvsRaw = attestationCcvRaw ? [attestationCcvRaw] : [] @@ -1438,6 +1491,9 @@ export class CantonChain extends Chain { /** * Exercise `UpdateRequiredCCVs` on an existing `CCIPReceiver` contract. + * + * The optional {@link TransactionSigner} selects the submission path + * (interactive vs. direct); see {@link submitCommands}. */ private async updateReceiverRequiredCCVs( receiverCid: string, @@ -1482,7 +1538,10 @@ export class CantonChain extends Chain { * The `OffRamp.PrepareExecute` Daml choice rejects messages whose `finality` field does not * match the receiver's `minBlockConfirmations`, so each distinct finality value needs its own * receiver instance. This method first searches the ACS; if no match is found it creates a - * fresh contract (mirroring the Go `deployReceiver` helper in the staging script). + * fresh contract. + * + * The optional {@link TransactionSigner} selects the submission path + * (interactive vs. direct); see {@link submitCommands}. */ private async createReceiverForFinality( payer: string, @@ -1794,8 +1853,14 @@ export class CantonChain extends Chain { } /** - * Ensure PerPartyRouter + CCIPSender disclosures exist for send (mirrors Go GetOrCreateRouter/Sender). - * Creates missing contracts when `signer` is provided. + * Ensure PerPartyRouter + CCIPSender disclosures exist for send. + * + * Creates missing contracts on demand. Canton authenticates via the ledger + * JWT (OIDC / static / client-credentials), so an external + * {@link TransactionSigner} is *not* required — when omitted, contract + * creation uses the direct `submitAndWaitForTransaction` path. When a + * signer is supplied, the interactive (prepare → sign → execute) path is + * used instead. */ private async ensureSendDisclosures( party: string, @@ -1804,13 +1869,6 @@ export class CantonChain extends Chain { let found = await this.acsDisclosureProvider.findSendDisclosures() if (!found.perPartyRouter) { - if (!signer) { - throw new CCIPError( - CCIPErrorCode.CANTON_API_ERROR, - `CantonChain: no active PerPartyRouter for party "${party}". ` + - 'Submit via CantonWallet.sendMessage to auto-create, or create one with the Go CLI.', - ) - } this.logger.debug( `CantonChain.ensureSendDisclosures: creating PerPartyRouter for party ${party}`, ) @@ -1822,13 +1880,6 @@ export class CantonChain extends Chain { } if (!found.ccipSender) { - if (!signer) { - throw new CCIPError( - CCIPErrorCode.CANTON_API_ERROR, - `CantonChain: no active CCIPSender for party "${party}". ` + - 'Submit via CantonWallet.sendMessage to auto-create, or create one with the Go CLI.', - ) - } this.logger.debug(`CantonChain.ensureSendDisclosures: creating CCIPSender for party ${party}`) await this.createCcipSender(party, signer) found = { @@ -1845,8 +1896,11 @@ export class CantonChain extends Chain { /** * Create a `PerPartyRouter` for `party` via the EDS factory disclosure. + * + * The optional {@link TransactionSigner} selects the submission path + * (interactive vs. direct); see {@link submitCommands}. */ - private async createPerPartyRouter(party: string, signer: TransactionSigner): Promise { + private async createPerPartyRouter(party: string, signer?: TransactionSigner): Promise { const factory = await this.edsDisclosureProvider.fetchPerPartyRouterFactoryDisclosures(party) const factoryTemplateId = `#${this.ccipPackages.perPartyRouter}:CCIP.RuntimeV2.PerPartyRouter:PerPartyRouterFactory` const createCmd: JsCommands = { @@ -1877,8 +1931,11 @@ export class CantonChain extends Chain { /** * Create a `CCIPSender` contract for `party` when none exists in ACS. + * + * The optional {@link TransactionSigner} selects the submission path + * (interactive vs. direct); see {@link submitCommands}. */ - private async createCcipSender(party: string, signer: TransactionSigner): Promise { + private async createCcipSender(party: string, signer?: TransactionSigner): Promise { const senderTemplateId = `#${this.ccipPackages.ccipSender}:CCIP.CCIPSender:CCIPSender` const createCmd: JsCommands = { commands: [ @@ -2546,7 +2603,6 @@ function decodeFinalityFromEncodedMessage(encodedHex: string): number { * Encode a numeric message finality as a Canton JSON Ledger API variant value for * the `receiverFinalityConfig : FinalityConfig` field of `CCIPReceiver`. * - * Mirrors Go's `encodeReceiverFinalityConfig` in ccip/devenv/manual_execution.go: * 0 → WaitForFinality (no block-depth threshold) * 0x00010000→ WaitForSafe (wait for the safe/finalized block) * N (other) → BlockDepth(N) (wait for N block confirmations) diff --git a/ccip-sdk/src/canton/token-metadata/client.ts b/ccip-sdk/src/canton/token-metadata/client.ts index ad9a573c0..50f64a2ec 100644 --- a/ccip-sdk/src/canton/token-metadata/client.ts +++ b/ccip-sdk/src/canton/token-metadata/client.ts @@ -85,8 +85,15 @@ export interface ListInstrumentsOptions { export interface TokenMetadataClientConfig { /** Base URL of the token registry (e.g. http://localhost:9000) */ baseUrl: string - /** Optional JWT for authentication */ - jwt?: string + /** + * Optional JWT for authentication. + * + * Pass a string for a static token, or a `() => Promise` getter for + * a refreshable token. When a getter is supplied, each request awaits it and + * uses the returned JWT in the `Authorization` header (enabling automatic + * refresh). + */ + jwt?: string | (() => Promise) /** Request timeout in milliseconds (default: 30 000) */ timeout?: number } @@ -102,10 +109,15 @@ export interface TokenMetadataClientConfig { */ export function createTokenMetadataClient(config: TokenMetadataClientConfig) { const baseUrl = config.baseUrl.replace(/\/$/, '') - console.log('Creating Token Metadata client with base URL:', baseUrl) - const headers = buildHeaders(config.jwt) + const jwt = config.jwt const timeoutMs = config.timeout ?? 30_000 + /** Resolve request headers, awaiting `jwt` when it is a function. */ + async function resolveHeaders(): Promise> { + const token = typeof jwt === 'function' ? await jwt() : jwt + return buildHeaders(token) + } + const appendScanProxyPath = (path: string) => `/v0/scan-proxy${path}` return { /** @@ -114,6 +126,7 @@ export function createTokenMetadataClient(config: TokenMetadataClientConfig) { * `GET /registry/metadata/v1/info` */ async getRegistryInfo(): Promise { + const headers = await resolveHeaders() return get( baseUrl, appendScanProxyPath('/registry/metadata/v1/info'), @@ -128,6 +141,7 @@ export function createTokenMetadataClient(config: TokenMetadataClientConfig) { * `GET /registry/metadata/v1/instruments` */ async listInstruments(options?: ListInstrumentsOptions): Promise { + const headers = await resolveHeaders() const queryParams: Record = {} if (options?.pageSize !== undefined) { queryParams['pageSize'] = String(options.pageSize) @@ -150,6 +164,7 @@ export function createTokenMetadataClient(config: TokenMetadataClientConfig) { * `GET /registry/metadata/v1/instruments/{instrumentId}` */ async getInstrument(instrumentId: string): Promise { + const headers = await resolveHeaders() return get( baseUrl, appendScanProxyPath( diff --git a/ccip-sdk/src/canton/transfer-instruction/client.ts b/ccip-sdk/src/canton/transfer-instruction/client.ts index ed9dde5b1..d7da3c50f 100644 --- a/ccip-sdk/src/canton/transfer-instruction/client.ts +++ b/ccip-sdk/src/canton/transfer-instruction/client.ts @@ -73,8 +73,15 @@ export interface TransferInstructionErrorResponse { export interface TransferInstructionClientConfig { /** Base URL of the token registry (e.g. http://localhost:9000) */ baseUrl: string - /** Optional JWT for authentication */ - jwt?: string + /** + * Optional JWT for authentication. + * + * Pass a string for a static token, or a `() => Promise` getter for + * a refreshable token. When a getter is supplied, each request awaits it and + * uses the returned JWT in the `Authorization` header (enabling automatic + * refresh). + */ + jwt?: string | (() => Promise) /** Request timeout in milliseconds (default: 30 000) */ timeout?: number /** @@ -95,9 +102,15 @@ export interface TransferInstructionClientConfig { */ export function createTransferInstructionClient(config: TransferInstructionClientConfig) { const baseUrl = config.baseUrl.replace(/\/$/, '') - const headers = buildHeaders(config.jwt) + const jwt = config.jwt const timeoutMs = config.timeout ?? 30_000 + /** Resolve request headers, awaiting `jwt` when it is a function. */ + async function resolveHeaders(): Promise> { + const token = typeof jwt === 'function' ? await jwt() : jwt + return buildHeaders(token) + } + const apiPath = (path: string) => (config.useScanProxy === false ? path : `/v0/scan-proxy${path}`) return { /** @@ -108,6 +121,7 @@ export function createTransferInstructionClient(config: TransferInstructionClien async getTransferFactory( request: GetFactoryRequest, ): Promise { + const headers = await resolveHeaders() return post( baseUrl, apiPath('/registry/transfer-instruction/v1/transfer-factory'), @@ -126,6 +140,7 @@ export function createTransferInstructionClient(config: TransferInstructionClien transferInstructionId: string, request?: GetChoiceContextRequest, ): Promise { + const headers = await resolveHeaders() return post( baseUrl, apiPath( @@ -146,6 +161,7 @@ export function createTransferInstructionClient(config: TransferInstructionClien transferInstructionId: string, request?: GetChoiceContextRequest, ): Promise { + const headers = await resolveHeaders() return post( baseUrl, apiPath( @@ -166,6 +182,7 @@ export function createTransferInstructionClient(config: TransferInstructionClien transferInstructionId: string, request?: GetChoiceContextRequest, ): Promise { + const headers = await resolveHeaders() return post( baseUrl, apiPath( diff --git a/ccip-sdk/src/canton/types.ts b/ccip-sdk/src/canton/types.ts index 876fbbfc6..e28b0dec1 100644 --- a/ccip-sdk/src/canton/types.ts +++ b/ccip-sdk/src/canton/types.ts @@ -84,8 +84,7 @@ export interface CantonInstrumentId { } /** - * Input for a single CCV that should verify the outbound send - * (maps to Go `ccipsender.CCVSendInput`). + * Input for a single CCV that should verify the outbound send. */ export interface CantonCCVSendInput { ccvCid: string @@ -94,8 +93,7 @@ export interface CantonCCVSendInput { } /** - * Token input carrying the Transfer Factory reference and metadata - * (maps to Go `interfaces.TokenInput`). + * Token input carrying the Transfer Factory reference and metadata. */ export interface CantonTokenInput { transferFactory: string @@ -104,8 +102,7 @@ export interface CantonTokenInput { } /** - * Extra arguments attached to a Canton token input - * (maps to Go `splice_api_token_metadata_v1.ExtraArgs`). + * Extra arguments attached to a Canton token input. */ export interface CantonTokenExtraArgs { context: { values: Record } diff --git a/ccip-sdk/src/chain.ts b/ccip-sdk/src/chain.ts index 3c840928a..b501b5ecc 100644 --- a/ccip-sdk/src/chain.ts +++ b/ccip-sdk/src/chain.ts @@ -210,8 +210,17 @@ export type CantonConfig = { /** CCIP operator party (CCIPSender signatory / fee recipient on ledger). */ ccipParty: string - /** JSON Web Token for authentication with the Canton Ledger API. */ - jwt: string + /** + * JSON Web Token for authentication with the Canton Ledger API. + * + * Pass a string for a static (pre-obtained) token, or a `() => Promise` + * getter for a refreshable token (e.g. an OAuth2 caching provider). When a + * getter is supplied, the SDK clients call it per request to obtain a fresh + * JWT, enabling automatic refresh. The CLI resolves its `auth` block upfront + * and injects either a string or a getter here; web/Electron embedders inject + * their own. + */ + jwt?: string | (() => Promise) /** Base URL for the EDS (Explicit Disclosure Service) API. */ edsUrl: string @@ -264,7 +273,7 @@ export type CantonConfig = { /** * Transfer-factory preview amount for Canton fee-token payments. - * Rarely needs changing; mirrors Go CLI transfer-factory `"1.0"` default. + * Rarely needs changing; defaults to `"1.0"`. */ feeTransferFactoryAmount?: string diff --git a/ccip-sdk/src/errors/codes.ts b/ccip-sdk/src/errors/codes.ts index 22e63ffd9..70e2f0410 100644 --- a/ccip-sdk/src/errors/codes.ts +++ b/ccip-sdk/src/errors/codes.ts @@ -186,6 +186,7 @@ export const CCIPErrorCode = { // Canton CANTON_API_ERROR: 'CANTON_API_ERROR', + CANTON_AUTH_ERROR: 'CANTON_AUTH_ERROR', } as const /** Union type of all error codes. */ diff --git a/ccip-sdk/src/errors/recovery.ts b/ccip-sdk/src/errors/recovery.ts index 45323a5b2..7350e5679 100644 --- a/ccip-sdk/src/errors/recovery.ts +++ b/ccip-sdk/src/errors/recovery.ts @@ -218,6 +218,8 @@ export const DEFAULT_RECOVERY_HINTS: Partial> = { CANTON_API_ERROR: 'Canton Ledger API returned an error. Verify the party ID is correct, the contract is active, and the Canton node is reachable.', + CANTON_AUTH_ERROR: + 'Canton authentication failed. Verify the JWT is valid and not expired, or check the OIDC auth_url, client_id, and client_secret (client credentials) or redirect URI (authorization code).', } /** Returns default recovery hint for error code, or undefined if none. */ diff --git a/ccip-sdk/src/index.ts b/ccip-sdk/src/index.ts index 80fdbf3cb..349b85197 100644 --- a/ccip-sdk/src/index.ts +++ b/ccip-sdk/src/index.ts @@ -164,6 +164,41 @@ export { resolveFeeTransferFactoryAmount, resolveSenderInstanceId, } from './canton/defaults.ts' +// Canton authentication (OAuth 2.0: static, clientCredentials, authorizationCode protocol helpers) +export { + type AccessToken, + type AnyAuthProvider, + type AuthConfig as CantonAuthConfig, + type AuthProvider as CantonAuthProvider, + type AuthProviderOptions as CantonAuthProviderOptions, + type AuthorizationCodeAuthConfig, + type AuthorizationCodeProtocolOptions, + type AuthorizationRequest, + type AuthorizationServerMetadata, + type ClientCredentialsAuthConfig, + type StaticAuthConfig, + type ValidatedCallback, + AuthorizationCodeProvider as CantonAuthorizationCodeProvider, + CantonAuthType, + ClientCredentialsProvider as CantonClientCredentialsProvider, + StaticProvider as CantonStaticProvider, + buildAuthorizationRequest as buildCantonAuthorizationRequest, + codeChallengeFromVerifier as cantonCodeChallengeFromVerifier, + createAuthProvider as createCantonAuthProvider, + createAuthorizationCodeProvider as createCantonAuthorizationCodeProvider, + createClientCredentialsProvider as createCantonClientCredentialsProvider, + createMemoizedTokenFetcher as createCantonMemoizedTokenFetcher, + createStaticProvider as createCantonStaticProvider, + exchangeAuthorizationCode as exchangeCantonAuthorizationCode, + generateCodeVerifier as generateCantonCodeVerifier, + generateState as generateCantonState, + getAuthorizationServerMetadata as getCantonAuthorizationServerMetadata, + isAccessToken as isCantonAccessToken, + isTokenExpired as isCantonTokenExpired, + refreshAuthorizationCodeToken as refreshCantonAuthorizationCodeToken, + resolveAuthorizationCodeConfig as resolveCantonAuthorizationCodeConfig, + validateAuthorizationCallback as validateCantonAuthorizationCallback, +} from './canton/index.ts' export { AptosChain, CantonChain, EVMChain, SolanaChain, SuiChain, TONChain } // use `supportedChains` to override/register derived classes, if needed export { supportedChains } from './supported-chains.ts' diff --git a/package-lock.json b/package-lock.json index 02baebe74..3435d0ade 100644 --- a/package-lock.json +++ b/package-lock.json @@ -172,6 +172,7 @@ "buffer": "^6.0.3", "ethers": "6.17.0", "micro-memoize": "^5.1.2", + "oauth4webapi": "^3.8.7", "type-fest": "^5.8.0", "yaml": "2.9.0" }, @@ -21934,6 +21935,15 @@ "node": ">= 6" } }, + "node_modules/oauth4webapi": { + "version": "3.8.7", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.7.tgz", + "integrity": "sha512-4RxcKxXjuItDFZ20RRPf4YTw3kpeXJyCgJFxVzJ068A7PNJ18st2Dg90tlC1LkSDS0GecroagCLHYEIVUhCAkw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",