From d6f562c45a7fb4946ce7d75bd042d00ba57ede9f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 10:11:33 -0700 Subject: [PATCH] chore: release main (#1025) Co-authored-by: dtmeadows Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 21 +- .release-please-manifest.json | 2 +- .stats.yml | 4 +- CHANGELOG.md | 8 + package.json | 2 +- packages/vertex-sdk/yarn.lock | 2 +- src/client.ts | 356 +++++- src/core/credentials.ts | 349 ++++++ src/internal/utils/time.ts | 4 + src/lib/credentials.ts | 3 + src/lib/credentials/credential-chain.ts | 284 +++++ src/lib/credentials/identity-token.ts | 37 + src/lib/credentials/oidc-federation.ts | 112 ++ src/lib/credentials/token-cache.ts | 130 +++ src/lib/credentials/types.ts | 295 +++++ src/lib/credentials/user-oauth.ts | 144 +++ src/version.ts | 2 +- tests/credentials.test.ts | 370 ++++++ .../credentials/client-integration.test.ts | 1004 +++++++++++++++++ .../lib/credentials/credential-chain.test.ts | 446 ++++++++ tests/lib/credentials/identity-token.test.ts | 67 ++ tests/lib/credentials/oidc-federation.test.ts | 247 ++++ tests/lib/credentials/token-cache.test.ts | 260 +++++ tests/lib/credentials/user-oauth.test.ts | 284 +++++ 24 files changed, 4400 insertions(+), 33 deletions(-) create mode 100644 src/core/credentials.ts create mode 100644 src/internal/utils/time.ts create mode 100644 src/lib/credentials.ts create mode 100644 src/lib/credentials/credential-chain.ts create mode 100644 src/lib/credentials/identity-token.ts create mode 100644 src/lib/credentials/oidc-federation.ts create mode 100644 src/lib/credentials/token-cache.ts create mode 100644 src/lib/credentials/types.ts create mode 100644 src/lib/credentials/user-oauth.ts create mode 100644 tests/credentials.test.ts create mode 100644 tests/lib/credentials/client-integration.test.ts create mode 100644 tests/lib/credentials/credential-chain.test.ts create mode 100644 tests/lib/credentials/identity-token.test.ts create mode 100644 tests/lib/credentials/oidc-federation.test.ts create mode 100644 tests/lib/credentials/token-cache.test.ts create mode 100644 tests/lib/credentials/user-oauth.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8f5de65..0b842585 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,22 +110,21 @@ jobs: - name: Run tests run: ./scripts/test - detect_breaking_changes: + detect_breaking_changes_vs_main: timeout-minutes: 10 - name: detect-breaking-changes + name: detect-breaking-changes-vs-main runs-on: ${{ github.repository == 'stainless-sdks/anthropic-typescript' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: | (github.event_name == 'push' && - github.ref != 'refs/heads/next' && !startsWith(github.ref, 'refs/heads/release-please--')) || github.event_name == 'pull_request' steps: - uses: actions/checkout@v6 - - name: Fetch history for current branch and target branches + - name: Fetch history for current branch and main run: | git fetch origin --filter=blob:none --no-tags --depth=2147483647 ${{ github.sha }} - git fetch origin --filter=blob:none --no-tags next main 2>/dev/null || true + git fetch origin --filter=blob:none --no-tags main 2>/dev/null || true - name: Set up Node uses: actions/setup-node@v4 @@ -137,14 +136,10 @@ jobs: - name: Determine base SHA run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - echo "BASE_SHA=${{ github.event.pull_request.base.sha }}" >> $GITHUB_ENV - else - BASE_SHA=$(git merge-base HEAD origin/next 2>/dev/null || git merge-base HEAD origin/main 2>/dev/null || echo "") - echo "BASE_SHA=$BASE_SHA" >> $GITHUB_ENV - fi - - - name: Detect breaking changes + BASE_SHA=$(git merge-base HEAD origin/main 2>/dev/null || echo "") + echo "BASE_SHA=$BASE_SHA" >> $GITHUB_ENV + + - name: Detect breaking changes vs. main if: env.BASE_SHA != '' run: | # Try to check out previous versions of the breaking change detection script. This ensures that diff --git a/.release-please-manifest.json b/.release-please-manifest.json index cdfeb788..5a026735 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,5 +1,5 @@ { - ".": "0.92.0", + ".": "0.93.0", "packages/vertex-sdk": "0.16.0", "packages/bedrock-sdk": "0.29.1", "packages/foundry-sdk": "0.2.3", diff --git a/.stats.yml b/.stats.yml index 2cbdd921..7027e4e5 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 91 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/anthropic/anthropic-4175787f21d24cc3d87dcecce2df2469d10f92eee8e4f00af8a6dd36f7e13ffa.yml +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/anthropic/anthropic-ad9228826393d94e86ecf4c22853ae51b1d4094960c836238b3ab79a1044be32.yml openapi_spec_hash: dc43ed54947d427a084a891b7c4a783a -config_hash: 486e52c2d1bedf2dca1e33dcf8132987 +config_hash: bbf09e23cb2e12b5bb8cbcee3044ceec diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ca444da..0d5fda07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.93.0 (2026-05-04) + +Full Changelog: [sdk-v0.92.0...sdk-v0.93.0](https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.92.0...sdk-v0.93.0) + +### Features + +* **client:** add Workload Identity Federation, interactive OAuth, and auth profiles ([d5d6abd](https://github.com/anthropics/anthropic-sdk-typescript/commit/d5d6abdb1976db389aca87553ffedab2414e0d77)) + ## 0.92.0 (2026-04-30) Full Changelog: [sdk-v0.91.1...sdk-v0.92.0](https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.91.1...sdk-v0.92.0) diff --git a/package.json b/package.json index 7632d777..18964351 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@anthropic-ai/sdk", - "version": "0.92.0", + "version": "0.93.0", "description": "The official TypeScript library for the Anthropic API", "author": "Anthropic ", "types": "dist/index.d.ts", diff --git a/packages/vertex-sdk/yarn.lock b/packages/vertex-sdk/yarn.lock index ef54e24b..bbbceb32 100644 --- a/packages/vertex-sdk/yarn.lock +++ b/packages/vertex-sdk/yarn.lock @@ -17,7 +17,7 @@ "@anthropic-ai/sdk@file:../../dist": # x-release-please-start-version - version "0.92.0" + version "0.93.0" # x-release-please-end-version dependencies: json-schema-to-ts "^3.1.1" diff --git a/src/client.ts b/src/client.ts index 6039f428..15895c5b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -14,6 +14,11 @@ import * as Opts from './internal/request-options'; import { stringifyQuery } from './internal/utils/query'; import { VERSION } from './version'; import * as Errors from './core/error'; +import type { AccessTokenProvider } from './lib/credentials/types'; +import { OAUTH_API_BETA_HEADER } from './lib/credentials/types'; +import { TokenCache } from './lib/credentials/token-cache'; +import { defaultCredentials, resolveCredentialsFromConfig } from './lib/credentials/credential-chain'; +import type { AnthropicConfig } from './core/credentials'; import * as Pagination from './core/pagination'; import { type PageCursorParams, @@ -247,6 +252,40 @@ import { } from './internal/utils/log'; import { isEmptyObj } from './internal/utils/values'; +/** + * Shared auth state. A `withOptions()` clone receives the parent's instance + * (unless the caller overrides auth options) so a clone created before lazy + * resolution settles observes the same provider/tokenCache/error/extraHeaders + * as the parent rather than starting an independent resolution. + */ +type AuthState = { + provider: AccessTokenProvider | null; + tokenCache: TokenCache | null; + resolution: Promise | null; + error: unknown; + extraHeaders: Record; + /** + * `base_url` from the resolved profile/config, normalized (no trailing + * slash). Stored on the shared auth state so `withOptions()` clones created + * before lazy resolution settles can still adopt it on their first request. + */ + baseURL?: string | undefined; +}; + +/** + * Per-request auth flags, keyed by the FinalRequestOptions object so + * caller-owned options aren't mutated. + */ +type RequestAuthFlags = { + usedTokenCache: boolean; + didRefreshFor401: boolean; +}; + +type InternalClientOptions = ClientOptions & { + __auth?: AuthState | undefined; + __baseURLIsExplicit?: boolean | undefined; +}; + export type ApiKeySetter = () => Promise; export interface ClientOptions { @@ -268,6 +307,40 @@ export interface ClientOptions { */ authToken?: string | null | undefined; + /** + * An {@link AccessTokenProvider} for OAuth/workload-identity authentication. + * + * When set, the provider is wrapped in a {@link TokenCache} and used for + * Bearer token auth on every request. Takes precedence over `authToken` + * but not `apiKey`. + * + * If omitted (and no `apiKey` or `authToken` is provided), the client + * automatically resolves credentials from config files or environment + * variables on the first request. + */ + credentials?: AccessTokenProvider | null | undefined; + + /** + * An {@link AnthropicConfig} object to resolve credentials from directly, + * bypassing config-file and environment-variable lookup. This is the + * TypeScript equivalent of Go's `option.WithConfig(cfg)`. + * + * Ignored when `credentials` is set. For `oidc_federation`, the SDK + * performs the jwt-bearer exchange in-process; for `user_oauth`, + * `authentication.credentials_path` must point at the credentials file. + */ + config?: AnthropicConfig | null | undefined; + + /** + * Name of a profile to load from `/configs/.json`. + * + * Equivalent to setting the `ANTHROPIC_PROFILE` environment variable, but + * scoped to this client instance. As an explicit constructor argument it + * takes precedence over `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` in the + * environment. Mutually exclusive with `credentials` and `config`. + */ + profile?: string | null | undefined; + /** * Override the default base URL for the API, e.g., "https://api.example.com/v2/" * @@ -353,6 +426,23 @@ export class BaseAnthropic { apiKey: string | null; authToken: string | null; + /** + * The active credential provider. Default credential resolution runs once + * at construction time. If it fails, the error is surfaced on every + * request and the client must be reconstructed — there is no retry path. + * + * Clones returned by {@link withOptions} share the parent's auth state + * (provider, token cache, pending resolution, and any resolution error) + * unless the caller passes an explicit `apiKey`, `authToken`, + * `credentials`, `config`, or `profile` override. + */ + get credentials(): AccessTokenProvider | null { + return this._authState.provider; + } + private _authState: AuthState; + private _baseURLIsExplicit: boolean; + private _requestAuthFlags = new WeakMap(); + baseURL: string; maxRetries: number; timeout: number; @@ -379,12 +469,18 @@ export class BaseAnthropic { * @param {Record} opts.defaultQuery - Default query parameters to include with every request to the API. * @param {boolean} [opts.dangerouslyAllowBrowser=false] - By default, client-side use of this library is not allowed, as it risks exposing your secret API credentials to attackers. */ - constructor({ - baseURL = readEnv('ANTHROPIC_BASE_URL'), - apiKey = readEnv('ANTHROPIC_API_KEY') ?? null, - authToken = readEnv('ANTHROPIC_AUTH_TOKEN') ?? null, - ...opts - }: ClientOptions = {}) { + constructor({ baseURL = readEnv('ANTHROPIC_BASE_URL'), apiKey, authToken, ...opts }: ClientOptions = {}) { + // An explicit `profile` is a constructor-level credential choice; when set, + // do not let env ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN shadow it. + if (apiKey === undefined) { + apiKey = opts.profile != null ? null : readEnv('ANTHROPIC_API_KEY') ?? null; + } + if (authToken === undefined) { + authToken = opts.profile != null ? null : readEnv('ANTHROPIC_AUTH_TOKEN') ?? null; + } + if (opts.profile != null && (opts.credentials != null || opts.config != null)) { + throw new TypeError('Pass at most one of `profile`, `credentials`, or `config`.'); + } const options: ClientOptions = { apiKey, authToken, @@ -399,6 +495,13 @@ export class BaseAnthropic { } this.baseURL = options.baseURL!; + // After destructuring, `baseURL` is the constructor arg or + // ANTHROPIC_BASE_URL — both count as an explicit choice that a profile + // base_url must not override. A falsy value means we fell through to the + // hardcoded default above and a profile may supply the host. withOptions() + // propagates the parent's flag via __baseURLIsExplicit so a non-overriding + // clone doesn't mistake the inherited baseURL for a caller-supplied one. + this._baseURLIsExplicit = (opts as InternalClientOptions).__baseURLIsExplicit ?? !!baseURL; this.timeout = options.timeout ?? BaseAnthropic.DEFAULT_TIMEOUT /* 10 minutes */; this.logger = options.logger ?? console; const defaultLogLevel = 'warn'; @@ -425,19 +528,114 @@ export class BaseAnthropic { options.defaultHeaders = { ...parsed, ...options.defaultHeaders }; } + const inherited = (opts as InternalClientOptions).__auth; + // Never persist the internal __auth handle on _options — it's a + // one-shot constructor signal, and leaking it through _options would + // cause withOptions() to spread a stale value into clones. + delete (options as InternalClientOptions).__auth; + delete (options as InternalClientOptions).__baseURLIsExplicit; this._options = options; this.apiKey = typeof apiKey === 'string' ? apiKey : null; this.authToken = authToken; + + if (inherited) { + this._authState = inherited; + if (!this._baseURLIsExplicit && inherited.baseURL) { + this.baseURL = inherited.baseURL; + } + } else { + this._authState = { provider: null, tokenCache: null, resolution: null, error: null, extraHeaders: {} }; + + // apiKey/authToken win over credentials/config/profile; don't build a + // token cache or resolve a config that the request path will then ignore. + if (this.apiKey == null && this.authToken == null) { + const credentials = options.credentials ?? null; + if (credentials) { + this._authState.provider = credentials; + this._authState.tokenCache = this._makeTokenCache(credentials); + } else if (options.config != null) { + const result = resolveCredentialsFromConfig(options.config, this._credentialResolverOptions()); + this._authState.provider = result.provider; + this._authState.tokenCache = this._makeTokenCache(result.provider); + this._authState.extraHeaders = result.extraHeaders; + this._applyCredentialBaseURL(result.baseURL); + } else if (options.profile != null) { + this._authState.resolution = this._resolveDefaultCredentials(options.profile); + } else { + // No explicit auth provided — lazily resolve from the credential + // chain on first request. Errors are captured into _auth.error and + // surfaced on first use rather than as an unhandled rejection. + this._authState.resolution = this._resolveDefaultCredentials(); + } + } + } + } + + /** + * Stores a profile/config-supplied base URL on the shared auth state and, if + * the caller did not pin `baseURL` via constructor option or env, adopts it + * as this client's outbound API host. Precedence: ctor opt > env > profile > + * hardcoded default. + */ + private _applyCredentialBaseURL(baseURL: string | undefined): void { + if (!baseURL) return; + const normalized = baseURL.replace(/\/+$/, ''); + this._authState.baseURL = normalized; + if (!this._baseURLIsExplicit) { + this.baseURL = normalized; + } + } + + /** + * Options bag passed into the credential chain. `baseURL` here is only the + * fallback host for the token-exchange POST when the config itself omits + * `base_url`; the chain returns the config's own `base_url` (if any) on + * {@link CredentialResult.baseURL}, which {@link _applyCredentialBaseURL} + * then adopts for outbound API requests. The two are deliberately decoupled + * so this fallback never round-trips into precedence. + */ + private _credentialResolverOptions() { + return { + baseURL: this.baseURL, + fetch: this.fetch, + userAgent: this.getUserAgent(), + onCacheWriteError: (err: unknown) => { + loggerFor(this).debug('credential cache write failed (best-effort)', err); + }, + onSafetyWarning: (msg: string) => { + loggerFor(this).warn(msg); + }, + }; + } + + private _makeTokenCache(provider: AccessTokenProvider): TokenCache { + return new TokenCache(provider, (err) => { + loggerFor(this).debug('advisory token refresh failed; serving cached token', err); + }); } /** * Create a new client instance re-using the same options given to the current client with optional overriding. */ withOptions(options: Partial): this { - const client = new (this.constructor as any as new (props: ClientOptions) => typeof this)({ + // Share the auth state object unless the caller passes any auth-related + // key. The `in` check is intentional: even `apiKey: undefined` opts the + // clone out of sharing (it gets its own _auth and TokenCache, though it + // may still wrap the parent's provider via the credentials spread below). + const overridesStructuredAuth = 'credentials' in options || 'config' in options || 'profile' in options; + const overridesAuth = 'apiKey' in options || 'authToken' in options || overridesStructuredAuth; + const internal: InternalClientOptions = { ...this._options, - baseURL: this.baseURL, + // Only forward baseURL when the caller (or env) explicitly chose it. + // For a non-explicit parent, this.baseURL may have been mutated to the + // profile-resolved host; pinning that as the clone's options.baseURL + // would make _options on the clone misreport caller intent and would + // leave the clone stuck on the parent's host across an auth override. + // The clone instead receives the construction-time value via + // ...this._options above and re-adopts the profile host through the + // shared _authState.baseURL + __baseURLIsExplicit=false path. + ...(this._baseURLIsExplicit ? { baseURL: this.baseURL } : {}), maxRetries: this.maxRetries, timeout: this.timeout, logger: this.logger, @@ -446,13 +644,61 @@ export class BaseAnthropic { fetchOptions: this.fetchOptions, apiKey: this.apiKey, authToken: this.authToken, + // credentials: this.credentials is a no-op when __auth is shared (the + // ctor takes the inherited path and ignores options.credentials); when + // overridesAuth is true via apiKey/authToken only, it lets the clone + // build a fresh TokenCache around the parent's provider. + credentials: this.credentials, + // When the caller passes a structured-credential override, drop inherited + // structured-credential options so only `...options` supplies them — + // otherwise an inherited `credentials`/`config`/`profile` would trip the + // mutual-exclusion check or precedence over the override. + ...(overridesStructuredAuth ? { credentials: undefined, config: undefined, profile: undefined } : {}), ...options, - }); - return client; + // Always set __auth so any stale value from ...this._options is + // overwritten. undefined means "build fresh auth from these options". + __auth: overridesAuth ? undefined : this._authState, + __baseURLIsExplicit: 'baseURL' in options ? true : this._baseURLIsExplicit, + }; + return new (this.constructor as any as new (props: ClientOptions) => typeof this)(internal); + } + + /** + * Lazily resolves credentials from config files or environment variables. + * Called once from the constructor when no explicit auth is provided, or + * when an explicit `profile` was passed (in which case a missing/unresolved + * profile is surfaced as an error instead of falling through to "no auth"). + * The returned promise is stored and awaited on the first request. + */ + private async _resolveDefaultCredentials(profile?: string): Promise { + try { + const result = await defaultCredentials(this._credentialResolverOptions(), profile); + if (result) { + this._authState.provider = result.provider; + this._authState.tokenCache = this._makeTokenCache(result.provider); + this._authState.extraHeaders = result.extraHeaders; + this._applyCredentialBaseURL(result.baseURL); + } else if (profile != null) { + throw new Errors.AnthropicError( + `Profile "${profile}" could not be resolved (no /configs/${profile}.json found).`, + ); + } + } catch (err) { + this._authState.error = err; + } finally { + this._authState.resolution = null; + } } /** * Check whether the base URL is set to its default. + * + * A profile-supplied `base_url` counts as an override here: a profile that + * pins a non-default host is declaring "this whole client targets deployment + * X", so per-endpoint {@link RequestOptions.defaultBaseURL} hints must not + * silently route individual calls back to production. No generated resource + * currently sets `defaultBaseURL`, so this is documenting intent for when + * one does. */ #baseURLOverridden(): boolean { return this.baseURL !== 'https://api.anthropic.com'; @@ -466,6 +712,12 @@ export class BaseAnthropic { if (values.get('x-api-key') || values.get('authorization')) { return; } + if (this._authState.error) { + throw this._authState.error; + } + if (this._authState.tokenCache || this._authState.resolution) { + return; // auth will be injected per-request via authHeaders + } if (this.apiKey && values.get('x-api-key')) { return; @@ -482,11 +734,35 @@ export class BaseAnthropic { } throw new Error( - 'Could not resolve authentication method. Expected either apiKey or authToken to be set. Or for one of the "X-Api-Key" or "Authorization" headers to be explicitly omitted', + 'Could not resolve authentication method. Expected one of apiKey, authToken, credentials, config, or profile to be set. Or for one of the "X-Api-Key" or "Authorization" headers to be explicitly omitted', ); } + private _authFlags(opts: FinalRequestOptions): RequestAuthFlags { + let flags = this._requestAuthFlags.get(opts); + if (!flags) { + flags = { usedTokenCache: false, didRefreshFor401: false }; + this._requestAuthFlags.set(opts, flags); + } + return flags; + } + protected async authHeaders(opts: FinalRequestOptions): Promise { + // Wait for lazy credential resolution if it's in progress. If it failed, + // return no auth headers — validateHeaders surfaces the stored error + // after the explicit-header escape hatch has had a chance to apply. + if (this._authState.resolution) { + await this._authState.resolution; + } + if (this._authState.error) { + return undefined; + } + // If we have a token cache and no API key is set, use token auth + if (this._authState.tokenCache && this.apiKey == null) { + const token = await this._authState.tokenCache.getToken(); + this._authFlags(opts).usedTokenCache = true; + return buildHeaders([{ Authorization: `Bearer ${token}` }]); + } return buildHeaders([await this.apiKeyAuth(opts), await this.bearerAuth(opts)]); } @@ -578,7 +854,28 @@ export class BaseAnthropic { protected async prepareRequest( request: RequestInit, { url, options }: { url: string; options: FinalRequestOptions }, - ): Promise {} + ): Promise { + // Append auth-derived headers when using token auth. Done here (after all + // header merging) rather than in authHeaders() so we append to any existing + // anthropic-beta values instead of being overwritten by later header sources. + if (this._authState.tokenCache && this.apiKey == null) { + // Normalize to a Headers instance — custom fetch impls or polyfills can + // hand back arrays / plain objects, and silently dropping the beta + // header in that case would surface as a confusing server-side 4xx. + const headers = request.headers instanceof Headers ? request.headers : new Headers(request.headers); + for (const [k, v] of Object.entries(this._authState.extraHeaders)) { + if (!headers.has(k)) headers.set(k, v); + } + const existing = headers + .get('anthropic-beta') + ?.split(',') + .map((s) => s.trim()); + if (!existing?.includes(OAUTH_API_BETA_HEADER)) { + headers.append('anthropic-beta', OAUTH_API_BETA_HEADER); + } + request.headers = headers; + } + } get(path: string, opts?: PromiseOrValue): APIPromise { return this.methodRequest('get', path, opts); @@ -628,6 +925,9 @@ export class BaseAnthropic { const maxRetries = options.maxRetries ?? this.maxRetries; if (retriesRemaining == null) { retriesRemaining = maxRetries; + // Top-level call: reset per-request auth flags so a reused options object + // (via client.request(opts)) doesn't carry stale 401-refresh state. + this._requestAuthFlags.delete(options); } await this.prepareOptions(options); @@ -716,7 +1016,7 @@ export class BaseAnthropic { } with status ${response.status} in ${headersTime - startTime}ms`; if (!response.ok) { - const shouldRetry = await this.shouldRetry(response); + const shouldRetry = await this.shouldRetry(response, options); if (retriesRemaining && shouldRetry) { const retryMessage = `retrying, ${retriesRemaining} attempts remaining`; @@ -846,7 +1146,24 @@ export class BaseAnthropic { } } - private async shouldRetry(response: Response): Promise { + private async shouldRetry(response: Response, options: FinalRequestOptions): Promise { + // Reactive refresh: on a 401 from a request that used the token cache, + // invalidate and retry once. Only fires when this specific request was + // bearer-authenticated (not when an apiKey was used) and only once per + // request — a second 401 after refresh falls through to the normal + // retry policy below (which treats 4xx as non-retryable). + const flags = this._authFlags(options); + if ( + response.status === 401 && + this._authState.tokenCache && + flags.usedTokenCache && + !flags.didRefreshFor401 + ) { + flags.didRefreshFor401 = true; + this._authState.tokenCache.invalidate(); + return true; + } + // Note this is not a standard header. const shouldRetryHeader = response.headers.get('x-should-retry'); @@ -944,6 +1261,17 @@ export class BaseAnthropic { const options = { ...inputOptions }; const { method, path, query, defaultBaseURL } = options; + // Lazy credential resolution may carry a profile-supplied baseURL. Await + // it before building the request URL so the very first request — and + // requests on withOptions() clones created before resolution settled — + // hit the profile's host rather than the hardcoded default. + if (this._authState.resolution) { + await this._authState.resolution; + } + if (!this._baseURLIsExplicit && this._authState.baseURL && this.baseURL !== this._authState.baseURL) { + this.baseURL = this._authState.baseURL; + } + const url = this.buildURL(path!, query as Record, defaultBaseURL); if ('timeout' in options) validatePositiveInteger('timeout', options.timeout); options.timeout = options.timeout ?? this.timeout; diff --git a/src/core/credentials.ts b/src/core/credentials.ts new file mode 100644 index 00000000..e4663ba1 --- /dev/null +++ b/src/core/credentials.ts @@ -0,0 +1,349 @@ +import { getPlatformHeaders } from '../internal/detect-platform'; +import { readEnv } from '../internal/utils'; + +/** Current schema version written to `configs/.json`. Absent on read ⇒ "1.0". */ +export const CONFIG_FILE_VERSION = '1.0'; +/** Current schema version written to `credentials/.json`. Absent on read ⇒ "1.0". */ +export const CREDENTIALS_FILE_VERSION = '1.0'; + +/** + * Authentication-mode-specific configuration. On the wire (configs/.json) + * this is a flat JSON object under the top-level `authentication` key — `type`, + * `credentials_path`, and the variant-specific fields all sit at the same level. + * + * Unknown fields are silently ignored for forward compatibility. Unknown + * authentication types are rejected because the SDK has no way to resolve + * credentials for them. + */ +export type AuthenticationInfo = { + /** + * Filesystem path to the credentials JSON that stores access/refresh tokens. + * Defaults to `/credentials/.json` when omitted. + */ + credentials_path?: string | undefined; +} & ( + | { + type: 'oidc_federation'; + /** Tagged ID (`fdrl_...`) of the federation rule. Required. */ + federation_rule_id: string; + /** Optional `svac_...` expected-target check. */ + service_account_id?: string | undefined; + identity_token?: + | { + source: 'file'; + path: string; + } + | undefined; + /** Display-only; the SDK does not send this on the jwt-bearer exchange. */ + scope?: string | undefined; + } + | { + type: 'user_oauth'; + /** OAuth client ID for refresh. Empty → access token is treated as static. */ + client_id?: string | undefined; + /** Display-only; the SDK does not send this on refresh. */ + scope?: string | undefined; + /** Console URL the profile was created against. Display-only. */ + console_url?: string | undefined; + } +); + +export type AnthropicConfig = { + version?: string; + authentication: AuthenticationInfo; + base_url?: string | undefined; + organization_id?: string | undefined; + workspace_id?: string | undefined; +}; + +export type AnthropicCredentials = { + version?: string; + type: 'oauth_token'; + access_token: string; + expires_at?: number; + refresh_token?: string; + scope?: string; + organization_uuid?: string; + organization_name?: string; + account_email?: string; +}; + +const PROFILE_NAME_PATTERN = /^[A-Za-z0-9_.-]+$/; + +function validateProfileName(name: string): void { + if (!name) { + throw new Error('profile name is empty'); + } + if (name === '.' || name === '..') { + throw new Error(`profile name "${name}" is not allowed`); + } + if (name.includes('/') || name.includes('\\')) { + throw new Error(`profile name "${name}" must not contain path separators`); + } + if (!PROFILE_NAME_PATTERN.test(name)) { + throw new Error( + `profile name "${name}" contains disallowed characters (allowed: letters, digits, '_', '.', '-')`, + ); + } +} + +/** + * Loads the Anthropic configuration for the given (or active) profile. + * + * Returns `null` when running in a browser or no configuration can be resolved. + * Otherwise, returns the configuration based on the config file and environment variables. + * + * **Profile resolution** (first match wins): + * 1. Explicit `profile` argument + * 2. `ANTHROPIC_PROFILE` environment variable + * 3. Contents of `/active_config` file + * 4. `"default"` + * + * **Config resolution:** + * - If `/configs/.json` exists, it is loaded and + * missing fields are filled from environment variables. Values present + * in the file take precedence — env vars only fill gaps: + * - `ANTHROPIC_BASE_URL` → `base_url` + * - `ANTHROPIC_ORGANIZATION_ID` → `organization_id` + * - `ANTHROPIC_SCOPE` → `authentication.scope` + * - `ANTHROPIC_FEDERATION_RULE_ID` → `authentication.federation_rule_id` (oidc_federation) + * - `ANTHROPIC_IDENTITY_TOKEN_FILE` → `authentication.identity_token` (oidc_federation) + * - `ANTHROPIC_SERVICE_ACCOUNT_ID` → `authentication.service_account_id` (oidc_federation) + * - If no config file exists, an `oidc_federation` config is synthesized + * entirely from environment variables when both `ANTHROPIC_FEDERATION_RULE_ID` + * and `ANTHROPIC_ORGANIZATION_ID` are set. + */ +export const loadConfig = async (profile?: string): Promise => { + const rootConfigPath = await getRootConfigPath(); + if (rootConfigPath === null) { + return null; + } + + const profileName = profile ?? (await getActiveProfileName()); + if (profileName === null) { + return null; + } + validateProfileName(profileName); + + const fs = await import('node:fs'); + const path = await import('node:path'); + const configPath = path.join(rootConfigPath, 'configs', `${profileName}.json`); + let configRaw: string | null; + try { + configRaw = await fs.promises.readFile(configPath, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') { + throw new Error(`failed to read config file ${configPath}: ${err}`); + } + configRaw = null; + } + if (configRaw === null) { + const organizationId = readEnv('ANTHROPIC_ORGANIZATION_ID'); + const identityTokenFile = readEnv('ANTHROPIC_IDENTITY_TOKEN_FILE'); + const federationRuleId = readEnv('ANTHROPIC_FEDERATION_RULE_ID'); + if (federationRuleId && organizationId) { + return { + organization_id: organizationId, + base_url: readEnv('ANTHROPIC_BASE_URL'), + authentication: { + type: 'oidc_federation', + federation_rule_id: federationRuleId, + service_account_id: readEnv('ANTHROPIC_SERVICE_ACCOUNT_ID'), + identity_token: identityTokenFile ? { source: 'file', path: identityTokenFile } : undefined, + scope: readEnv('ANTHROPIC_SCOPE'), + }, + }; + } + return null; + } + + let config: AnthropicConfig; + try { + config = JSON.parse(configRaw); + } catch (err) { + throw new Error(`failed to parse config file ${configPath}: ${err}`); + } + if (!config.authentication) { + throw new Error(`config file ${configPath} is missing "authentication"`); + } + const authType = config.authentication.type; + if (authType !== 'oidc_federation' && authType !== 'user_oauth') { + throw new Error(`authentication.type "${authType}" is not a known authentication type`); + } + + // File values are authoritative; env vars only fill fields the file left unset. + config.organization_id ??= readEnv('ANTHROPIC_ORGANIZATION_ID'); + config.base_url ??= readEnv('ANTHROPIC_BASE_URL'); + config.authentication.scope ??= readEnv('ANTHROPIC_SCOPE'); + + if (config.authentication.type === 'oidc_federation') { + if (!config.authentication.identity_token) { + const identityTokenFile = readEnv('ANTHROPIC_IDENTITY_TOKEN_FILE'); + if (identityTokenFile) { + config.authentication.identity_token = { + source: 'file', + path: identityTokenFile, + }; + } + } + + // Unlike siblings using `??= readEnv()` (which leaves `undefined`), coerce + // to '' so the type stays `string` (always set). The downstream required + // check in credential-chain rejects empty, so semantics match but types are + // cleaner. + if (!config.authentication.federation_rule_id) { + config.authentication.federation_rule_id = readEnv('ANTHROPIC_FEDERATION_RULE_ID') ?? ''; + } + config.authentication.service_account_id ??= readEnv('ANTHROPIC_SERVICE_ACCOUNT_ID'); + } + + return config; +}; + +/** + * Loads the credential material for the active profile. + * + * Returns the parsed credentials or `null` when running in a browser or + * no credentials file can be found. + * + * **Profile resolution** (first match wins): + * 1. `ANTHROPIC_PROFILE` environment variable + * 2. Contents of `/active_config` file + * 3. `"default"` + * + * **Credentials path resolution** (first match wins): + * 1. `authentication.credentials_path` from the active profile's config (via {@link loadConfig}) + * 2. `/credentials/.json` + */ +export const loadCredentials = async (): Promise => { + const config = await loadConfig(); + const credentialsPath = await getCredentialsPath(config); + if (!credentialsPath) { + return null; + } + + const fs = await import('node:fs'); + let raw: string; + try { + raw = await fs.promises.readFile(credentialsPath, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') { + throw new Error(`failed to read credentials file ${credentialsPath}: ${err}`); + } + return null; + } + + let creds: AnthropicCredentials; + try { + creds = JSON.parse(raw); + } catch (err) { + throw new Error(`failed to parse credentials file ${credentialsPath}: ${err}`); + } + if (creds.type && creds.type !== 'oauth_token') { + throw new Error( + `credentials file ${credentialsPath} has unsupported type "${creds.type}" (want "oauth_token")`, + ); + } + return creds; +}; + +/** + * Resolves the credentials file path for the given config. + * + * Uses `authentication.credentials_path` from the config if set, otherwise + * falls back to `/credentials/.json`. + * + * Returns `null` when running in a browser or the path cannot be resolved. + */ +export const getCredentialsPath = async ( + config: AnthropicConfig | null, + profile?: string, +): Promise => { + if (config?.authentication.credentials_path) { + return config.authentication.credentials_path; + } + + const rootConfigPath = await getRootConfigPath(); + if (!rootConfigPath) { + return null; + } + + const profileName = profile ?? (await getActiveProfileName()); + if (!profileName) { + return null; + } + validateProfileName(profileName); + + const path = await import('node:path'); + return path.join(rootConfigPath, 'credentials', `${profileName}.json`); +}; + +const getRootConfigPath = async (): Promise => { + if (!supportsLocalConfigFiles()) { + return null; + } + + const path = await import('node:path'); + + // ANTHROPIC_CONFIG_DIR is treated as a trusted path: it is set by the + // process operator, not by remote input, so it is not validated. + const configDir = readEnv('ANTHROPIC_CONFIG_DIR'); + if (configDir) { + return configDir; + } + + const os = getPlatformHeaders()['X-Stainless-OS']; + if (os === 'Windows') { + const appData = readEnv('APPDATA'); + if (appData) { + return path.join(appData, 'Anthropic'); + } + const userProfile = readEnv('USERPROFILE'); + if (userProfile) { + return path.join(userProfile, 'AppData', 'Roaming', 'Anthropic'); + } + // No usable Windows config root — return null so callers fall through to + // "no config available" rather than silently writing under C:\. + return null; + } + + const xdgConfigHome = readEnv('XDG_CONFIG_HOME'); + if (xdgConfigHome) { + return path.join(xdgConfigHome, 'anthropic'); + } + + const home = readEnv('HOME'); + if (home) { + return path.join(home, '.config', 'anthropic'); + } + return null; +}; + +const supportsLocalConfigFiles = (): boolean => { + const runtime = getPlatformHeaders()['X-Stainless-Runtime']; + return runtime === 'node' || runtime === 'deno'; +}; + +const getActiveProfileName = async (): Promise => { + const rootConfigPath = await getRootConfigPath(); + if (!rootConfigPath) { + return null; + } + + const profileName = readEnv('ANTHROPIC_PROFILE'); + if (profileName) { + return profileName; + } + + const fs = await import('node:fs'); + const path = await import('node:path'); + const filePath = path.join(rootConfigPath, 'active_config'); + try { + return (await fs.promises.readFile(filePath, 'utf-8')).trim() || 'default'; + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') { + throw new Error(`failed to read ${filePath}: ${err}`); + } + return 'default'; + } +}; diff --git a/src/internal/utils/time.ts b/src/internal/utils/time.ts new file mode 100644 index 00000000..b2745459 --- /dev/null +++ b/src/internal/utils/time.ts @@ -0,0 +1,4 @@ +/** Current time as unix epoch seconds. */ +export function nowAsSeconds(): number { + return Math.floor(Date.now() / 1000); +} diff --git a/src/lib/credentials.ts b/src/lib/credentials.ts new file mode 100644 index 00000000..0e3399e3 --- /dev/null +++ b/src/lib/credentials.ts @@ -0,0 +1,3 @@ +export { type AccessToken, type AccessTokenProvider } from './credentials/types'; +export { type AnthropicConfig, type AuthenticationInfo, loadConfig } from '../core/credentials'; +export { resolveCredentialsFromConfig } from './credentials/credential-chain'; diff --git a/src/lib/credentials/credential-chain.ts b/src/lib/credentials/credential-chain.ts new file mode 100644 index 00000000..4fd675a0 --- /dev/null +++ b/src/lib/credentials/credential-chain.ts @@ -0,0 +1,284 @@ +import type { Fetch } from '../../internal/builtin-types'; +import { readEnv } from '../../internal/utils/env'; +import { + CREDENTIALS_FILE_VERSION, + loadConfig, + getCredentialsPath, + type AnthropicConfig, +} from '../../core/credentials'; +import type { AccessTokenProvider, CredentialResult, IdentityTokenProvider } from './types'; +import { + MANDATORY_REFRESH_THRESHOLD_IN_SECONDS, + WorkloadIdentityError, + checkCredentialsFileSafety, + writeCredentialsFileAtomic, +} from './types'; +import { nowAsSeconds } from '../../internal/utils/time'; +import { identityTokenFromFile, identityTokenFromValue } from './identity-token'; +import { oidcFederationProvider } from './oidc-federation'; +import { userOAuthProvider } from './user-oauth'; + +/** + * Builds a {@link CredentialResult} from an explicit {@link AnthropicConfig}. + * + * Use this when constructing a client from an in-memory config object rather + * than from profile files or environment variables. + * + * For `oidc_federation`, `authentication.credentials_path` is optional — + * if omitted, every call performs a fresh exchange with no on-disk cache. + * For `user_oauth`, `authentication.credentials_path` is required (it is + * where the access/refresh tokens live). + */ +export type ResolverOptions = { + baseURL: string; + fetch: Fetch; + userAgent?: string | undefined; + onCacheWriteError?: ((err: unknown) => void) | undefined; + onSafetyWarning?: ((msg: string) => void) | undefined; +}; + +export function resolveCredentialsFromConfig( + config: AnthropicConfig, + options: ResolverOptions, +): CredentialResult { + const credentialsPath = config.authentication.credentials_path ?? null; + const effectiveBaseURL = (config.base_url || options.baseURL).replace(/\/+$/, ''); + + const provider = buildProvider(config, credentialsPath, effectiveBaseURL, options); + + const extraHeaders: Record = {}; + // Workspace scoping for oidc_federation is server-side (the federation rule + // encodes the workspace and the minted token is workspace-scoped), so the + // header is only meaningful for user_oauth. + if (config.workspace_id && config.authentication.type === 'user_oauth') { + extraHeaders['anthropic-workspace-id'] = config.workspace_id; + } + + // Surface the profile's own base_url (not the options.baseURL fallback) so + // the client can adopt it for outbound API requests when the caller didn't + // pin one explicitly. Echoing options.baseURL back would defeat precedence. + return { provider, extraHeaders, baseURL: config.base_url || undefined }; +} + +/** + * Resolves a {@link CredentialResult} from the environment. Returns `null` + * when no credentials can be resolved. + * + * Resolution order: + * + * 1. Config file for the active profile (or the explicit `profile` argument) + * → dispatch on `authentication.type` (`oidc_federation`, `user_oauth`) + * 2. Environment variables `ANTHROPIC_FEDERATION_RULE_ID` + + * `ANTHROPIC_ORGANIZATION_ID` (+ identity token) → OIDC federation + * 3. Nothing matches → `null` + * + * Passing `profile` selects `/configs/.json` directly, + * skipping `ANTHROPIC_PROFILE` / `active_config` resolution. + */ +export async function defaultCredentials( + options: ResolverOptions, + profile?: string, +): Promise { + const config = await loadConfig(profile); + if (!config) { + return null; + } + + // For env/file-loaded configs, default credentials_path to the + // per-profile location so user_oauth and federation caching work. + // Shallow-clone first so callers that retain a reference to the loaded + // config don't observe the patched-in default. + const withPath: AnthropicConfig = + config.authentication.credentials_path ? + config + : { + ...config, + authentication: { + ...config.authentication, + credentials_path: (await getCredentialsPath(config, profile)) ?? undefined, + }, + }; + + return resolveCredentialsFromConfig(withPath, options); +} + +function buildProvider( + config: AnthropicConfig, + credentialsPath: string | null, + baseURL: string, + options: ResolverOptions, +): AccessTokenProvider { + switch (config.authentication.type) { + case 'oidc_federation': { + const auth = config.authentication; + const identityProvider = resolveIdentityTokenProvider(auth); + if (!identityProvider) { + throw new WorkloadIdentityError( + 'oidc_federation config requires an identity token (set authentication.identity_token, ' + + 'ANTHROPIC_IDENTITY_TOKEN_FILE, or ANTHROPIC_IDENTITY_TOKEN)', + ); + } + if (!auth.federation_rule_id) { + throw new WorkloadIdentityError( + "oidc_federation config requires 'federation_rule_id'. Set it in authentication.federation_rule_id in your profile, or via ANTHROPIC_FEDERATION_RULE_ID (profile takes precedence).", + ); + } + if (!config.organization_id) { + throw new WorkloadIdentityError( + 'oidc_federation config requires organization_id (set ANTHROPIC_ORGANIZATION_ID or config.organization_id)', + ); + } + + const exchange = oidcFederationProvider({ + identityTokenProvider: identityProvider, + federationRuleId: auth.federation_rule_id, + organizationId: config.organization_id, + serviceAccountId: auth.service_account_id, + baseURL, + fetch: options.fetch, + userAgent: options.userAgent, + }); + + // If there's a credentials file path, wrap the exchange with file caching + // (check file for fresh token before exchanging, write back after). + if (credentialsPath) { + return cachedExchangeProvider( + exchange, + credentialsPath, + options.onCacheWriteError, + options.onSafetyWarning, + ); + } + return exchange; + } + + case 'user_oauth': { + if (!credentialsPath) { + throw new WorkloadIdentityError( + 'user_oauth config requires authentication.credentials_path ' + + '(or load via a profile so it defaults to /credentials/.json)', + ); + } + return userOAuthProvider({ + credentialsPath, + clientId: config.authentication.client_id, + baseURL, + fetch: options.fetch, + userAgent: options.userAgent, + onSafetyWarning: options.onSafetyWarning, + }); + } + + default: { + const t = (config.authentication as { type: string }).type; + throw new WorkloadIdentityError(`authentication.type "${t}" is not a known authentication type`); + } + } +} + +/** + * Resolves the identity token provider from config fields or environment variables. + * + * Resolution order: + * 1. `identity_token.path` from the config (source: "file") + * 2. `ANTHROPIC_IDENTITY_TOKEN_FILE` env var + * 3. `ANTHROPIC_IDENTITY_TOKEN` env var (static value) + */ +function resolveIdentityTokenProvider( + auth: Extract, +): IdentityTokenProvider | null { + if (auth.identity_token) { + // Cast needed to stringify an unknown source value for the error message: + // the on-disk JSON may contain a source this SDK version doesn't know about. + const source = (auth.identity_token as { source: string }).source; + if (source !== 'file') { + throw new WorkloadIdentityError( + `identity_token.source "${source}" is not supported by this SDK version (only "file")`, + ); + } + if (!auth.identity_token.path) { + throw new WorkloadIdentityError(`identity_token.source "file" requires a non-empty path`); + } + return identityTokenFromFile(auth.identity_token.path); + } + + const tokenFile = readEnv('ANTHROPIC_IDENTITY_TOKEN_FILE'); + if (tokenFile) { + return identityTokenFromFile(tokenFile); + } + + const tokenValue = readEnv('ANTHROPIC_IDENTITY_TOKEN'); + if (tokenValue) { + return identityTokenFromValue(tokenValue); + } + + return null; +} + +/** + * Wraps a federation exchange provider with credential file caching. + * Checks the file for a fresh token before exchanging, and writes the + * result back after a successful exchange (best-effort, atomic replace). + * + * Note: this is not cross-process serialized — two SDK instances that + * miss the cache simultaneously will both perform a full exchange and + * the last writer wins. That is acceptable: federation exchanges are + * idempotent and the cache is an optimization, not a correctness gate. + */ +function cachedExchangeProvider( + exchange: AccessTokenProvider, + credentialsPath: string, + onCacheWriteError: ((err: unknown) => void) | undefined, + onSafetyWarning: ((msg: string) => void) | undefined, +): AccessTokenProvider { + return async (opts) => { + const fs = await import('node:fs'); + + await checkCredentialsFileSafety(credentialsPath, onSafetyWarning); + + // Try cached credentials file + let existing: Record | undefined; + try { + const raw = await fs.promises.readFile(credentialsPath, 'utf-8'); + existing = JSON.parse(raw); + const token = existing?.['access_token'] as string | undefined; + if (token && !opts?.forceRefresh) { + const expiresAt = existing?.['expires_at'] as number | undefined; + if (expiresAt == null || nowAsSeconds() < expiresAt - MANDATORY_REFRESH_THRESHOLD_IN_SECONDS) { + return { token, expiresAt: expiresAt ?? null }; + } + } + } catch (err) { + // ENOENT or invalid-JSON → no usable cache, exchange fresh. Other + // errors (EACCES, EISDIR, …) indicate a broken cache path; surface to + // the optional hook so they're at least debuggable, then proceed. + const code = (err as NodeJS.ErrnoException)?.code; + if (code !== 'ENOENT' && !(err instanceof SyntaxError)) { + onCacheWriteError?.(err); + } + } + + // Exchange for a new token + const result = await exchange(opts); + + // Write cache back (best-effort). Preserve any unknown keys from the + // existing file (notably refresh_token, in the unlikely case this path + // is shared with a user_oauth profile) so the federation cache writer + // doesn't clobber material it didn't own. + try { + await writeCredentialsFileAtomic(credentialsPath, { + ...(existing ?? {}), + version: CREDENTIALS_FILE_VERSION, + type: 'oauth_token', + access_token: result.token, + expires_at: result.expiresAt, + }); + } catch (err) { + // Best-effort caching: surface to the optional hook but never fail + // the exchange itself. + onCacheWriteError?.(err); + } + + return result; + }; +} diff --git a/src/lib/credentials/identity-token.ts b/src/lib/credentials/identity-token.ts new file mode 100644 index 00000000..3f847e5d --- /dev/null +++ b/src/lib/credentials/identity-token.ts @@ -0,0 +1,37 @@ +import { AnthropicError } from '../../core/error'; +import type { IdentityTokenProvider } from './types'; + +/** + * Reads a JWT from a file on every call. Supports automatic rotation + * (e.g. Kubernetes projected service-account tokens). + */ +export function identityTokenFromFile(path: string): IdentityTokenProvider { + if (!path) { + throw new AnthropicError('Identity token file path is empty'); + } + + return async () => { + const fs = await import('node:fs'); + let content: string; + try { + content = await fs.promises.readFile(path, 'utf-8'); + } catch (err) { + throw new AnthropicError(`Failed to read identity token file at ${path}: ${err}`); + } + const token = content.trim(); + if (!token) { + throw new AnthropicError(`Identity token file at ${path} is empty`); + } + return token; + }; +} + +/** + * Wraps a static JWT string as an {@link IdentityTokenProvider}. + */ +export function identityTokenFromValue(token: string): IdentityTokenProvider { + if (!token) { + throw new AnthropicError('Identity token value is empty'); + } + return () => token; +} diff --git a/src/lib/credentials/oidc-federation.ts b/src/lib/credentials/oidc-federation.ts new file mode 100644 index 00000000..4c89d819 --- /dev/null +++ b/src/lib/credentials/oidc-federation.ts @@ -0,0 +1,112 @@ +import type { Fetch } from '../../internal/builtin-types'; +import type { AccessTokenProvider, IdentityTokenProvider } from './types'; +import { + FEDERATION_BETA_HEADER, + GRANT_TYPE_JWT_BEARER, + OAUTH_API_BETA_HEADER, + TOKEN_ENDPOINT, + WorkloadIdentityError, + parseTokenResponse, + redactSensitive, + requireSecureTokenEndpoint, +} from './types'; +import { nowAsSeconds } from '../../internal/utils/time'; +import { VERSION } from '../../version'; + +export type OIDCFederationConfig = { + identityTokenProvider: IdentityTokenProvider; + federationRuleId: string; + organizationId: string; + serviceAccountId?: string | undefined; + baseURL: string; + fetch: Fetch; + /** + * Overrides the outgoing User-Agent header on the token exchange. When + * empty, sends an SDK-identified UA so the token endpoint's access logs + * identify the caller. + */ + userAgent?: string | undefined; +}; + +/** + * Exchanges an external OIDC JWT for an Anthropic access token via the + * RFC 7523 jwt-bearer grant. + * + * Each invocation performs a fresh token exchange. Wrap in a + * {@link TokenCache} to avoid exchanging on every request. + * + * Federation grants do not return a refresh token — callers re-exchange + * their assertion on expiry. + */ +export function oidcFederationProvider(config: OIDCFederationConfig): AccessTokenProvider { + return async () => { + requireSecureTokenEndpoint(config.baseURL); + + const jwt = await config.identityTokenProvider(); + // The token endpoint enforces a 16 KiB assertion limit; surface a clear + // client-side error so misconfigured projected-token sources are + // diagnosable without a server round-trip. + if (jwt.length > 16 * 1024) { + throw new WorkloadIdentityError( + `Identity token is ${Math.ceil(jwt.length / 1024)} KiB, exceeds the 16 KiB assertion limit`, + ); + } + + const body: Record = { + grant_type: GRANT_TYPE_JWT_BEARER, + assertion: jwt, + federation_rule_id: config.federationRuleId, + organization_id: config.organizationId, + }; + if (config.serviceAccountId) { + body['service_account_id'] = config.serviceAccountId; + } + + const url = `${config.baseURL}${TOKEN_ENDPOINT}`; + let resp: Response; + try { + resp = await config.fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'anthropic-beta': `${OAUTH_API_BETA_HEADER},${FEDERATION_BETA_HEADER}`, + 'User-Agent': config.userAgent || `anthropic-sdk-typescript/${VERSION} oidcFederationProvider`, + }, + body: JSON.stringify(body), + }); + } catch (err) { + throw new WorkloadIdentityError(`Failed to reach token endpoint ${url}: ${err}`); + } + + const requestId = resp.headers.get('Request-Id'); + + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + const redacted = redactSensitive(text); + throw new WorkloadIdentityError( + `Token exchange failed with status ${resp.status}${ + requestId ? ` (request-id ${requestId})` : '' + }: ${redacted}`, + resp.status, + redacted, + requestId, + ); + } + + const data = await parseTokenResponse(resp, requestId); + const expiresIn = Number(data.expires_in); + if (!Number.isFinite(expiresIn)) { + throw new WorkloadIdentityError( + `Token endpoint response missing required fields: ${JSON.stringify(redactSensitive(data))}`, + resp.status, + redactSensitive(data), + requestId, + ); + } + + return { + token: data.access_token, + expiresAt: nowAsSeconds() + expiresIn, + }; + }; +} diff --git a/src/lib/credentials/token-cache.ts b/src/lib/credentials/token-cache.ts new file mode 100644 index 00000000..490531e7 --- /dev/null +++ b/src/lib/credentials/token-cache.ts @@ -0,0 +1,130 @@ +import type { AccessToken, AccessTokenProvider } from './types'; +import { + ADVISORY_REFRESH_BACKOFF_IN_SECONDS, + ADVISORY_REFRESH_THRESHOLD_IN_SECONDS, + MANDATORY_REFRESH_THRESHOLD_IN_SECONDS, +} from './types'; +import { nowAsSeconds } from '../../internal/utils/time'; + +/** + * Wraps an {@link AccessTokenProvider} with two-tier proactive refresh + * and concurrent deduplication. + * + * Refresh policy on each {@link getToken} call: + * + * - No cached token → call provider (blocking), cache, return. + * - Cached with `expiresAt == null` → return cached forever. + * - More than 120s remaining → return cached. + * - 30–120s remaining (advisory window) → return stale token immediately, + * kick off background refresh. On failure, log and keep stale. + * - Less than 30s remaining or expired (mandatory) → block and refresh. + * On failure, throw. + * + * Concurrent mandatory callers coalesce into a single provider call. + */ +export class TokenCache { + private provider: AccessTokenProvider; + private cached: AccessToken | null = null; + private pendingRefresh: Promise | null = null; + private nextForce = false; + private lastAdvisoryError = 0; + private onAdvisoryRefreshError: ((err: unknown) => void) | undefined; + + constructor(provider: AccessTokenProvider, onAdvisoryRefreshError?: (err: unknown) => void) { + this.provider = provider; + this.onAdvisoryRefreshError = onAdvisoryRefreshError; + } + + async getToken(): Promise { + const force = this.nextForce; + this.nextForce = false; + const cached = this.cached; + + if (force || cached == null) { + const token = await this.refresh(force); + return token.token; + } + + if (cached.expiresAt == null) { + return cached.token; + } + + const remaining = cached.expiresAt - nowAsSeconds(); + + if (remaining > ADVISORY_REFRESH_THRESHOLD_IN_SECONDS) { + return cached.token; + } + + if (remaining > MANDATORY_REFRESH_THRESHOLD_IN_SECONDS) { + this.backgroundRefresh(); + return cached.token; + } + + const token = await this.refresh(); + return token.token; + } + + /** + * Clears the cached token and marks the next {@link getToken} as a forced + * refresh, so the underlying provider bypasses any on-disk freshness check. + * Called after a 401 — the server has just told us the token is bad even + * if its `expires_at` still looks fresh. + */ + invalidate(): void { + this.cached = null; + this.nextForce = true; + } + + /** + * Mandatory refresh. Joins any in-flight refresh unless forced — a forced + * refresh must not coalesce into a non-forced one that may re-serve the + * same stale disk token. + */ + private refresh(force = false): Promise { + if (this.pendingRefresh && !force) { + return this.pendingRefresh; + } + return this.doRefresh(force); + } + + /** + * Advisory background refresh. Shares the same in-flight promise as + * mandatory refreshes for deduplication, but swallows errors so the + * stale cached token keeps being served. Backs off for + * {@link ADVISORY_REFRESH_BACKOFF_IN_SECONDS} after a failure so an + * outage during the advisory window doesn't hammer the token endpoint. + */ + private backgroundRefresh(): void { + if (this.pendingRefresh) { + return; + } + if (nowAsSeconds() - this.lastAdvisoryError < ADVISORY_REFRESH_BACKOFF_IN_SECONDS) { + return; + } + this.doRefresh().catch((err) => { + this.lastAdvisoryError = nowAsSeconds(); + // Advisory failure: keep serving the stale cached token, but surface + // the error to the caller-provided hook so it can be logged. + this.onAdvisoryRefreshError?.(err); + }); + } + + /** + * Core refresh. Sets {@link pendingRefresh} so concurrent callers + * (both advisory and mandatory) coalesce into a single provider call. + */ + private doRefresh(force = false): Promise { + this.pendingRefresh = this.provider(force ? { forceRefresh: true } : undefined).then( + (token) => { + this.cached = token; + this.pendingRefresh = null; + return token; + }, + (err) => { + this.pendingRefresh = null; + throw err; + }, + ); + return this.pendingRefresh; + } +} diff --git a/src/lib/credentials/types.ts b/src/lib/credentials/types.ts new file mode 100644 index 00000000..5784d9a1 --- /dev/null +++ b/src/lib/credentials/types.ts @@ -0,0 +1,295 @@ +import { AnthropicError } from '../../core/error'; + +export type AccessToken = { + token: string; + /** Unix epoch seconds. `null` means no expiry (cache forever). */ + expiresAt: number | null; +}; + +/** + * Mints or returns a cached access token. + * + * The optional `opts.forceRefresh` flag, set by {@link TokenCache.invalidate} + * after a 401, tells providers with on-disk caches (user_oauth, cachedExchange) + * to bypass their freshness short-circuit and always fetch fresh. Providers + * without a cache can ignore it. + */ +export type AccessTokenProvider = (opts?: { forceRefresh?: boolean }) => Promise; + +export type IdentityTokenProvider = () => string | Promise; + +export type CredentialResult = { + provider: AccessTokenProvider; + extraHeaders: Record; + /** + * The `base_url` from the resolved config/profile, if any. The client + * applies this to outbound API requests when no explicit `baseURL` (constructor + * option or `ANTHROPIC_BASE_URL` env) was given, so a profile pointing at a + * non-default API host both mints its token against that host AND sends + * subsequent API requests there. + */ + baseURL?: string | undefined; +}; + +/** Response body from `POST /v1/oauth/token`. */ +export type TokenEndpointResponse = { + access_token?: string; + expires_in?: number; + refresh_token?: string; +}; + +export const GRANT_TYPE_JWT_BEARER = 'urn:ietf:params:oauth:grant-type:jwt-bearer'; +export const GRANT_TYPE_REFRESH_TOKEN = 'refresh_token'; +export const TOKEN_ENDPOINT = '/v1/oauth/token'; + +/** + * `anthropic-beta` value required on authenticated API requests using an + * OAuth bearer token, and on `refresh_token` grants against the token endpoint. + */ +export const OAUTH_API_BETA_HEADER = 'oauth-2025-04-20'; + +/** + * `anthropic-beta` value required on jwt-bearer exchanges against the token + * endpoint. It routes the request to the federation service; it must NOT be + * sent on `refresh_token` grants, which are handled by a different backend. + */ +export const FEDERATION_BETA_HEADER = 'oidc-federation-2026-04-01'; + +export const ADVISORY_REFRESH_THRESHOLD_IN_SECONDS = 120; +export const MANDATORY_REFRESH_THRESHOLD_IN_SECONDS = 30; +export const ADVISORY_REFRESH_BACKOFF_IN_SECONDS = 5; + +const MAX_TOKEN_RESPONSE_BYTES = 1 << 20; + +/** + * Rejects base URLs that would cause a JWT assertion or refresh token to be + * sent over cleartext HTTP. Loopback hosts are allowed for local development. + */ +export function requireSecureTokenEndpoint(baseURL: string): void { + if (!baseURL) return; + let u: URL; + try { + u = new URL(baseURL); + } catch (err) { + throw new WorkloadIdentityError(`Invalid token endpoint base URL "${baseURL}": ${err}`); + } + if (u.protocol === 'https:') return; + // WHATWG URL.hostname returns bracketed IPv6 ("[::1]"); Go's net/url strips them. + const host = u.hostname.toLowerCase().replace(/^\[|\]$/g, ''); + if (u.protocol === 'http:' && (host === 'localhost' || host === '127.0.0.1' || host === '::1')) { + return; + } + throw new WorkloadIdentityError(`Refusing to send credential over non-https token endpoint "${baseURL}"`); +} + +/** + * Reads the response body as text, parses it as a token-endpoint JSON + * response, validates `access_token` is present, and rejects a non-Bearer + * `token_type` when one is provided. Reads at most + * {@link MAX_TOKEN_RESPONSE_BYTES} from the body stream. + */ +export async function parseTokenResponse( + resp: Response, + requestId: string | null, +): Promise { + const text = await readLimitedText(resp); + let data: TokenEndpointResponse & { token_type?: string }; + try { + data = JSON.parse(text); + } catch { + throw new WorkloadIdentityError( + `Token endpoint returned non-JSON response (status ${resp.status})`, + resp.status, + redactSensitive(text), + requestId, + ); + } + if (!data.access_token) { + throw new WorkloadIdentityError( + `Token endpoint response missing access_token: ${JSON.stringify(redactSensitive(data))}`, + resp.status, + redactSensitive(data), + requestId, + ); + } + if (data.token_type && data.token_type.toLowerCase() !== 'bearer') { + throw new WorkloadIdentityError( + `Token endpoint response: unsupported token_type "${data.token_type}" (want Bearer)`, + resp.status, + redactSensitive(data), + requestId, + ); + } + return data as TokenEndpointResponse & { access_token: string }; +} + +const MAX_ERROR_BODY_CHARS = 2000; +// RFC 6749 §5.2 standard error-response fields. Anything else in a token +// endpoint error body is potentially echoed input (assertion, refresh_token, +// access_token, …) and is dropped rather than allowlisted-with-exceptions. +const SAFE_ERROR_KEYS = new Set(['error', 'error_description', 'error_uri']); + +/** + * Returns a redacted copy of a token-endpoint error body for safe inclusion + * in an exception. Strings are truncated; objects keep only the RFC 6749 + * §5.2 error fields. + */ +export function redactSensitive(body: unknown): unknown { + if (body == null) return body; + if (typeof body === 'string') { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + if (body.length <= MAX_ERROR_BODY_CHARS) return body; + return body.slice(0, MAX_ERROR_BODY_CHARS) + `... <${body.length - MAX_ERROR_BODY_CHARS} more chars>`; + } + return JSON.stringify(redactSensitive(parsed)); + } + if (typeof body === 'object' && !Array.isArray(body)) { + const out: Record = {}; + for (const [k, v] of Object.entries(body)) { + if (SAFE_ERROR_KEYS.has(k)) out[k] = v; + } + return out; + } + return null; +} + +/** + * Best-effort safety check on a credentials file before reading it. + * + * On POSIX: resolves symlinks (so containerized deployments that mount the + * credential as a symlink to a tmpfs-backed file keep working), then rejects + * the resolved target if it is group- or world- readable or writable. A uid + * mismatch on the resolved target is surfaced via `onWarn` since + * root-written/app-read is common in init-container setups. No-op on Windows. + */ +export async function checkCredentialsFileSafety( + path: string, + onWarn: (msg: string) => void = (m) => console.warn(`anthropic-sdk: ${m}`), +): Promise { + if (typeof process === 'undefined' || process.platform === 'win32') return; + const fs = await import('node:fs'); + let resolved = path; + let st; + try { + resolved = await fs.promises.realpath(path); + st = await fs.promises.stat(resolved); + } catch { + return; // ENOENT etc — let the subsequent read surface a precise error + } + const mode = st.mode & 0o777; + // 0o022 = group/world write; 0o044 = group/world read. + if (mode & 0o022) { + throw new WorkloadIdentityError( + `Credentials file at ${resolved} is group/world-writable (mode 0o${mode.toString(8)}); ` + + `this allows other local users to plant tokens. Run \`chmod 600 ${resolved}\`.`, + ); + } + if (mode & 0o044) { + throw new WorkloadIdentityError( + `Credentials file at ${resolved} is group/world-readable (mode 0o${mode.toString(8)}); ` + + `run \`chmod 600 ${resolved}\` before retrying.`, + ); + } + if (typeof process.getuid === 'function' && st.uid !== process.getuid()) { + onWarn( + `credentials file at ${resolved} is owned by uid ${ + st.uid + } (current process uid ${process.getuid()}); ` + `verify this is intentional.`, + ); + } +} + +/** + * Atomically writes JSON to `targetPath` via a `.tmp` sibling + rename, + * with fsync on the file and (best-effort) on the parent directory. + * Creates the parent directory with mode 0700 and the file with mode 0600. + */ +export async function writeCredentialsFileAtomic(targetPath: string, data: unknown): Promise { + const fs = await import('node:fs'); + const path = await import('node:path'); + const dir = path.dirname(targetPath); + await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 }); + // Unique temp name avoids two concurrent writers (different processes or + // SDK instances) racing on the same '.tmp' sibling and corrupting each + // other's bytes mid-write before the rename. + const tmpPath = `${targetPath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`; + try { + const fh = await fs.promises.open(tmpPath, 'w', 0o600); + try { + await fh.writeFile(JSON.stringify(data, null, 2)); + await fh.sync(); + } finally { + await fh.close(); + } + await fs.promises.rename(tmpPath, targetPath); + } catch (err) { + // Don't leak the temp file if anything between create and rename failed. + await fs.promises.unlink(tmpPath).catch(() => {}); + throw err; + } + // fsync the parent directory so the rename survives a crash. + try { + const dirFh = await fs.promises.open(dir, 'r'); + try { + await dirFh.sync(); + } finally { + await dirFh.close(); + } + } catch { + // Directory fsync is best-effort (unsupported on some platforms, e.g. Windows). + } +} + +async function readLimitedText(resp: Response): Promise { + if (!resp.body) { + return ''; + } + const reader = resp.body.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (received + value.length > MAX_TOKEN_RESPONSE_BYTES) { + const remaining = MAX_TOKEN_RESPONSE_BYTES - received; + if (remaining > 0) chunks.push(value.subarray(0, remaining)); + await reader.cancel(); + break; + } + chunks.push(value); + received += value.length; + } + let merged: Uint8Array; + if (chunks.length === 1) { + merged = chunks[0]!; + } else { + merged = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0)); + let offset = 0; + for (const c of chunks) { + merged.set(c, offset); + offset += c.length; + } + } + return new TextDecoder('utf-8').decode(merged); +} + +export class WorkloadIdentityError extends AnthropicError { + readonly statusCode: number | null; + readonly body: unknown; + readonly requestId: string | null; + + constructor( + message: string, + statusCode: number | null = null, + body: unknown = null, + requestId: string | null = null, + ) { + super(message); + this.statusCode = statusCode; + this.body = body; + this.requestId = requestId; + } +} diff --git a/src/lib/credentials/user-oauth.ts b/src/lib/credentials/user-oauth.ts new file mode 100644 index 00000000..36290203 --- /dev/null +++ b/src/lib/credentials/user-oauth.ts @@ -0,0 +1,144 @@ +import type { Fetch } from '../../internal/builtin-types'; +import { CREDENTIALS_FILE_VERSION, type AnthropicCredentials } from '../../core/credentials'; +import type { AccessTokenProvider } from './types'; +import { + GRANT_TYPE_REFRESH_TOKEN, + MANDATORY_REFRESH_THRESHOLD_IN_SECONDS, + OAUTH_API_BETA_HEADER, + TOKEN_ENDPOINT, + WorkloadIdentityError, + checkCredentialsFileSafety, + parseTokenResponse, + redactSensitive, + requireSecureTokenEndpoint, + writeCredentialsFileAtomic, +} from './types'; +import { nowAsSeconds } from '../../internal/utils/time'; +import { VERSION } from '../../version'; + +export type UserOAuthConfig = { + credentialsPath: string; + clientId?: string | undefined; + baseURL: string; + fetch: Fetch; + userAgent?: string | undefined; + onSafetyWarning?: ((msg: string) => void) | undefined; +}; + +/** + * Reads a user-oauth credential file. Returns the cached access token while + * fresh; on expiry performs a `refresh_token` grant and writes the new + * tokens back to the credentials file (atomic replace, fsync'd). + * + * If `clientId` is empty, the access token is treated as static — the + * credentials file is read on every call but no refresh is attempted, and + * an expired token without a `refresh_token` raises. + */ +export function userOAuthProvider(config: UserOAuthConfig): AccessTokenProvider { + return async (opts) => { + const fs = await import('node:fs'); + + await checkCredentialsFileSafety(config.credentialsPath, config.onSafetyWarning); + + let raw: string; + try { + raw = await fs.promises.readFile(config.credentialsPath, 'utf-8'); + } catch (err) { + throw new WorkloadIdentityError(`Credentials file not found at ${config.credentialsPath}: ${err}`); + } + let creds: AnthropicCredentials; + try { + creds = JSON.parse(raw); + } catch (err) { + throw new WorkloadIdentityError( + `Credentials file at ${config.credentialsPath} is not valid JSON: ${err}`, + ); + } + + const accessToken = creds.access_token; + if (!accessToken) { + throw new WorkloadIdentityError( + `Credentials file at ${config.credentialsPath} must include 'access_token'`, + ); + } + + // Return cached token if still fresh (or no expiry info), unless the + // caller is forcing a refresh after a 401 — then go straight to refresh + // even if the file's expires_at still looks valid. + const expiresAt = creds.expires_at; + if ( + !opts?.forceRefresh && + (expiresAt == null || nowAsSeconds() < expiresAt - MANDATORY_REFRESH_THRESHOLD_IN_SECONDS) + ) { + return { token: accessToken, expiresAt: expiresAt ?? null }; + } + + const refreshToken = creds.refresh_token; + if (!config.clientId || !refreshToken) { + throw new WorkloadIdentityError( + `Access token at ${config.credentialsPath} has expired and no refresh is available ` + + `(client_id ${config.clientId ? 'set' : 'empty'}, refresh_token ${refreshToken ? 'set' : 'empty'})`, + ); + } + + requireSecureTokenEndpoint(config.baseURL); + + const body: Record = { + grant_type: GRANT_TYPE_REFRESH_TOKEN, + refresh_token: refreshToken, + client_id: config.clientId, + }; + + const url = `${config.baseURL}${TOKEN_ENDPOINT}`; + let resp: Response; + try { + resp = await config.fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'anthropic-beta': OAUTH_API_BETA_HEADER, + 'User-Agent': config.userAgent || `anthropic-sdk-typescript/${VERSION} userOAuthProvider`, + }, + body: JSON.stringify(body), + }); + } catch (err) { + throw new WorkloadIdentityError(`User OAuth refresh failed to reach token endpoint: ${err}`); + } + + const requestId = resp.headers.get('Request-Id'); + + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + throw new WorkloadIdentityError( + `User OAuth refresh failed (HTTP ${resp.status}): ${redactSensitive(text)}`, + resp.status, + redactSensitive(text), + requestId, + ); + } + + const data = await parseTokenResponse(resp, requestId); + const expiresIn = Number(data.expires_in); + if (!Number.isFinite(expiresIn)) { + throw new WorkloadIdentityError( + `User OAuth refresh response missing or invalid expires_in: ${JSON.stringify(redactSensitive(data))}`, + resp.status, + redactSensitive(data), + requestId, + ); + } + const newExpiresAt = nowAsSeconds() + expiresIn; + const newRefreshToken = data.refresh_token || refreshToken; + + await writeCredentialsFileAtomic(config.credentialsPath, { + ...creds, + version: CREDENTIALS_FILE_VERSION, + type: 'oauth_token', + access_token: data.access_token, + expires_at: newExpiresAt, + refresh_token: newRefreshToken, + }); + + return { token: data.access_token, expiresAt: newExpiresAt }; + }; +} diff --git a/src/version.ts b/src/version.ts index 4457d999..37e6f5a8 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = '0.92.0'; // x-release-please-version +export const VERSION = '0.93.0'; // x-release-please-version diff --git a/tests/credentials.test.ts b/tests/credentials.test.ts new file mode 100644 index 00000000..951ef7bf --- /dev/null +++ b/tests/credentials.test.ts @@ -0,0 +1,370 @@ +import fs, { mkdtempSync } from 'node:fs'; +import { + AnthropicConfig, + AnthropicCredentials, + loadConfig, + loadCredentials, +} from '@anthropic-ai/sdk/core/credentials'; +import path from 'node:path'; +import { tmpdir } from 'node:os'; + +const isRunningInBrowserMock = jest.fn(); +const osNameMock = jest.fn(); +const runtimeMock = jest.fn(); + +jest.mock('../src/internal/detect-platform', () => ({ + isRunningInBrowser: () => isRunningInBrowserMock(), + getPlatformHeaders: () => ({ + 'X-Stainless-OS': osNameMock(), + 'X-Stainless-Runtime': runtimeMock(), + }), +})); + +describe('credentials', () => { + let testFolder = ''; + const originalEnv = [ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_CONFIG_DIR', + 'ANTHROPIC_FEDERATION_RULE_ID', + 'ANTHROPIC_IDENTITY_TOKEN_FILE', + 'ANTHROPIC_ORGANIZATION_ID', + 'ANTHROPIC_PROFILE', + 'ANTHROPIC_SCOPE', + 'ANTHROPIC_SERVICE_ACCOUNT_ID', + 'APPDATA', + 'HOME', + 'XDG_CONFIG_HOME', + ].reduce( + (env, name) => { + env[name] = process.env[name]; + return env; + }, + {} as Record, + ); + + const writeTestFile = (fileName: string, contents: string) => { + const upperPath = path.join(testFolder, process.env['ANTHROPIC_CONFIG_DIR'] ? '' : 'Anthropic', fileName); + fs.mkdirSync(path.dirname(upperPath), { recursive: true }); + fs.writeFileSync(upperPath, contents); + try { + const lowerPath = path.join( + testFolder, + process.env['ANTHROPIC_CONFIG_DIR'] ? '' : 'anthropic', + fileName, + ); + fs.mkdirSync(path.dirname(lowerPath), { recursive: true }); + fs.writeFileSync(lowerPath, contents); + } catch { + // Throws on case-insensitive systems + } + }; + + beforeEach(() => { + isRunningInBrowserMock.mockClear().mockReturnValue(false); + runtimeMock.mockClear().mockReturnValue('node'); + if (process.platform === 'win32') { + osNameMock.mockReturnValue('Windows'); + } else if (process.platform === 'darwin') { + osNameMock.mockReturnValue('MacOS'); + } else { + osNameMock.mockReturnValue('Linux'); + } + + testFolder = mkdtempSync(path.join(tmpdir(), 'credentials-test-')); + process.env['ANTHROPIC_CONFIG_DIR'] = testFolder; + }); + + afterEach(() => { + for (const [key, value] of Object.entries(originalEnv)) { + if (value) { + process.env[key] = value; + } else { + delete process.env[key]; + } + } + + fs.rmSync(testFolder, { recursive: true }); + }); + + test('loadConfig returns null if profile does not exist', async () => { + expect(await loadConfig()).toBe(null); + }); + + test('loadConfig loads default profile', async () => { + const config: AnthropicConfig = { + authentication: { type: 'user_oauth' }, + }; + writeTestFile('configs/default.json', JSON.stringify(config)); + + expect(await loadConfig()).toEqual(config); + }); + + test('loadConfig loads custom active profile based on active_config file', async () => { + const config: AnthropicConfig = { + authentication: { type: 'user_oauth' }, + }; + writeTestFile('active_config', 'mock_test_profile'); + writeTestFile('configs/mock_test_profile.json', JSON.stringify(config)); + + expect(await loadConfig()).toEqual(config); + }); + + test('loadConfig with explicit profile arg overrides ANTHROPIC_PROFILE and active_config', async () => { + const fromEnv: AnthropicConfig = { authentication: { type: 'user_oauth' }, organization_id: 'env' }; + const fromArg: AnthropicConfig = { authentication: { type: 'user_oauth' }, organization_id: 'arg' }; + process.env['ANTHROPIC_PROFILE'] = 'env_profile'; + writeTestFile('active_config', 'pointer_profile'); + writeTestFile('configs/env_profile.json', JSON.stringify(fromEnv)); + writeTestFile('configs/arg_profile.json', JSON.stringify(fromArg)); + + expect(await loadConfig('arg_profile')).toEqual(fromArg); + }); + + test('loadConfig prefers ANTHROPIC_PROFILE environment variable over active_config file', async () => { + const mockTestConfig: AnthropicConfig = { + authentication: { type: 'user_oauth' }, + organization_id: 'backup_config', + }; + const preferredTestProfile: AnthropicConfig = { + authentication: { type: 'user_oauth' }, + organization_id: 'preferred_test_profile', + }; + process.env['ANTHROPIC_PROFILE'] = 'preferred_test_profile'; + writeTestFile('active_config', 'mock_test_profile'); + writeTestFile('configs/mock_test_profile.json', JSON.stringify(mockTestConfig)); + writeTestFile('configs/preferred_test_profile.json', JSON.stringify(preferredTestProfile)); + + expect(await loadConfig()).toEqual(preferredTestProfile); + }); + + test('loadConfig: file organization_id wins over ANTHROPIC_ORGANIZATION_ID; env fills only when absent', async () => { + writeTestFile( + 'configs/default.json', + JSON.stringify({ authentication: { type: 'user_oauth' }, organization_id: 'from_file' }), + ); + process.env['ANTHROPIC_ORGANIZATION_ID'] = 'from_env'; + expect((await loadConfig())?.organization_id).toBe('from_file'); + + writeTestFile('configs/default.json', JSON.stringify({ authentication: { type: 'user_oauth' } })); + expect((await loadConfig())?.organization_id).toBe('from_env'); + }); + + test('loadConfig: file base_url wins over ANTHROPIC_BASE_URL; env fills only when absent', async () => { + writeTestFile( + 'configs/default.json', + JSON.stringify({ authentication: { type: 'user_oauth' }, base_url: 'https://from-file.example.com' }), + ); + process.env['ANTHROPIC_BASE_URL'] = 'https://from-env.example.com'; + expect((await loadConfig())?.base_url).toBe('https://from-file.example.com'); + + writeTestFile('configs/default.json', JSON.stringify({ authentication: { type: 'user_oauth' } })); + expect((await loadConfig())?.base_url).toBe('https://from-env.example.com'); + }); + + test('loadConfig: file federation_rule_id wins over ANTHROPIC_FEDERATION_RULE_ID', async () => { + writeTestFile( + 'configs/default.json', + JSON.stringify({ + organization_id: 'org_123', + authentication: { type: 'oidc_federation', federation_rule_id: 'from_file' }, + }), + ); + process.env['ANTHROPIC_FEDERATION_RULE_ID'] = 'from_env'; + + const cfg = await loadConfig(); + expect(cfg?.authentication.type === 'oidc_federation' && cfg.authentication.federation_rule_id).toBe( + 'from_file', + ); + }); + + test('loadConfig: file identity_token wins over ANTHROPIC_IDENTITY_TOKEN_FILE', async () => { + writeTestFile( + 'configs/default.json', + JSON.stringify({ + organization_id: 'org_123', + authentication: { + type: 'oidc_federation', + federation_rule_id: 'rule_123', + identity_token: { source: 'file', path: '/original/token/path' }, + }, + }), + ); + process.env['ANTHROPIC_IDENTITY_TOKEN_FILE'] = '/from-env/token/path'; + + const cfg = await loadConfig(); + expect(cfg?.authentication.type === 'oidc_federation' && cfg.authentication.identity_token).toEqual({ + source: 'file', + path: '/original/token/path', + }); + }); + + test('loadConfig does not apply ANTHROPIC_FEDERATION_RULE_ID to non-oidc_federation configs', async () => { + const config: AnthropicConfig = { + authentication: { type: 'user_oauth' }, + }; + writeTestFile('configs/default.json', JSON.stringify(config)); + + process.env['ANTHROPIC_FEDERATION_RULE_ID'] = 'should_be_ignored'; + + const result = await loadConfig(); + expect(result).toEqual(config); + expect(result?.authentication).not.toHaveProperty('federation_rule_id'); + }); + + test('loadConfig does not apply ANTHROPIC_IDENTITY_TOKEN_FILE to non-oidc_federation configs', async () => { + const config: AnthropicConfig = { + authentication: { type: 'user_oauth' }, + }; + writeTestFile('configs/default.json', JSON.stringify(config)); + + process.env['ANTHROPIC_IDENTITY_TOKEN_FILE'] = '/should/be/ignored'; + + const result = await loadConfig(); + expect(result).toEqual(config); + expect(result?.authentication).not.toHaveProperty('identity_token'); + }); + + test('loadConfig synthesizes oidc_federation config from env vars when no config file exists', async () => { + process.env['ANTHROPIC_FEDERATION_RULE_ID'] = 'fdrl_01abc'; + process.env['ANTHROPIC_ORGANIZATION_ID'] = 'org-uuid-123'; + process.env['ANTHROPIC_IDENTITY_TOKEN_FILE'] = '/var/run/secrets/token'; + process.env['ANTHROPIC_SERVICE_ACCOUNT_ID'] = 'svac_01abc'; + process.env['ANTHROPIC_SCOPE'] = 'user:inference'; + + expect(await loadConfig()).toEqual({ + organization_id: 'org-uuid-123', + authentication: { + type: 'oidc_federation', + federation_rule_id: 'fdrl_01abc', + identity_token: { + source: 'file', + path: '/var/run/secrets/token', + }, + service_account_id: 'svac_01abc', + scope: 'user:inference', + }, + }); + }); + + test('loadConfig synthesizes minimal oidc_federation config from only required env vars', async () => { + process.env['ANTHROPIC_FEDERATION_RULE_ID'] = 'fdrl_01abc'; + process.env['ANTHROPIC_ORGANIZATION_ID'] = 'org-uuid-123'; + + expect(await loadConfig()).toEqual({ + organization_id: 'org-uuid-123', + authentication: { + type: 'oidc_federation', + federation_rule_id: 'fdrl_01abc', + }, + }); + }); + + test('loadConfig returns null when no config file and only partial oidc_federation env vars', async () => { + process.env['ANTHROPIC_FEDERATION_RULE_ID'] = 'fdrl_01abc'; + // ANTHROPIC_ORGANIZATION_ID intentionally not set + + expect(await loadConfig()).toBe(null); + }); + + test('loadConfig surfaces non-ENOENT read errors instead of falling through', async () => { + // Create configs/default.json as a directory → EISDIR on read + fs.mkdirSync(path.join(testFolder, 'configs', 'default.json'), { recursive: true }); + + await expect(loadConfig()).rejects.toThrow(/failed to read config file/); + }); + + test('loadConfig throws descriptive error on malformed JSON', async () => { + writeTestFile('configs/default.json', '{not json'); + + await expect(loadConfig()).rejects.toThrow(/failed to parse config file .*default\.json/); + }); + + test('loadConfig throws on missing authentication', async () => { + writeTestFile('configs/default.json', JSON.stringify({ organization_id: 'org-123' })); + + await expect(loadConfig()).rejects.toThrow('missing "authentication"'); + }); + + test('loadConfig throws on unknown authentication type', async () => { + writeTestFile('configs/default.json', JSON.stringify({ authentication: { type: 'mystery' } })); + + await expect(loadConfig()).rejects.toThrow('not a known authentication type'); + }); + + test('loadConfig tolerates unknown keys (forward-compat)', async () => { + writeTestFile( + 'configs/default.json', + JSON.stringify({ + authentication: { type: 'user_oauth', client_id: 'abc', _comment: 'ignored', future_field: 42 }, + future_top_level: 'ignored', + }), + ); + + const result = await loadConfig(); + expect(result?.authentication.type).toBe('user_oauth'); + expect((result?.authentication as { client_id?: string }).client_id).toBe('abc'); + }); + + test('loadConfig rejects profile names with path separators', async () => { + process.env['ANTHROPIC_PROFILE'] = '../etc'; + await expect(loadConfig()).rejects.toThrow('must not contain path separators'); + }); + + test('loadCredentials returns null if profile does not exist', async () => { + expect(await loadCredentials()).toBe(null); + }); + + test('loadCredentials loads credentials for default profile', async () => { + const credentials: AnthropicCredentials = { + type: 'oauth_token', + access_token: 'foobar', + expires_at: 123, + }; + writeTestFile('credentials/default.json', JSON.stringify(credentials)); + + expect(await loadCredentials()).toEqual(credentials); + }); + + test('loadCredentials loads credentials for custom active profile', async () => { + const credentials: AnthropicCredentials = { + type: 'oauth_token', + access_token: 'barbaz', + expires_at: 456, + }; + writeTestFile('active_config', 'mock_test_profile'); + writeTestFile('credentials/mock_test_profile.json', JSON.stringify(credentials)); + + expect(await loadCredentials()).toEqual(credentials); + }); + + test('loadCredentials rejects unsupported credentials type', async () => { + writeTestFile('credentials/default.json', JSON.stringify({ type: 'api_key', access_token: 'x' })); + + await expect(loadCredentials()).rejects.toThrow('unsupported type "api_key"'); + }); + + test('loadCredentials tolerates missing type (treats as oauth_token)', async () => { + writeTestFile('credentials/default.json', JSON.stringify({ access_token: 'x' })); + + expect(await loadCredentials()).toEqual({ access_token: 'x' }); + }); + + test('loadCredentials loads file from credentials_path in config', async () => { + process.env['ANTHROPIC_PROFILE'] = 'credentials_path_test'; + const credentialsFilePath = path.join(testFolder, 'test-credentials.json'); + const config: AnthropicConfig = { + organization_id: '...', + authentication: { type: 'user_oauth', credentials_path: credentialsFilePath }, + }; + writeTestFile('configs/credentials_path_test.json', JSON.stringify(config)); + + const credentials: AnthropicCredentials = { + type: 'oauth_token', + access_token: 'token-123', + expires_at: 789, + }; + writeTestFile('test-credentials.json', JSON.stringify(credentials)); + + expect(await loadCredentials()).toEqual(credentials); + }); +}); diff --git a/tests/lib/credentials/client-integration.test.ts b/tests/lib/credentials/client-integration.test.ts new file mode 100644 index 00000000..d5b09bda --- /dev/null +++ b/tests/lib/credentials/client-integration.test.ts @@ -0,0 +1,1004 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import Anthropic from '@anthropic-ai/sdk'; +import type { AccessToken } from '@anthropic-ai/sdk/lib/credentials/types'; +import { OAUTH_API_BETA_HEADER } from '@anthropic-ai/sdk/lib/credentials/types'; + +const VALID_MSG_RESPONSE = { + id: 'msg_1', + type: 'message', + role: 'assistant', + content: [], + model: 'x', + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 }, +}; + +function jsonResponse(body: object, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function farFuture(): number { + return Math.floor(Date.now() / 1000) + 3600; +} + +function getHeader(init: RequestInit | undefined, name: string): string | null { + if (!init?.headers) return null; + if (init.headers instanceof Headers) return init.headers.get(name); + if (Array.isArray(init.headers)) { + const entry = init.headers.find(([k]) => k?.toLowerCase() === name.toLowerCase()); + return entry?.[1] ?? null; + } + return (init.headers as Record)[name] ?? null; +} + +describe('client credentials integration', () => { + let testDir: string; + const originalEnv: Record = {}; + + const envVars = [ + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_CONFIG_DIR', + 'ANTHROPIC_FEDERATION_RULE_ID', + 'ANTHROPIC_IDENTITY_TOKEN', + 'ANTHROPIC_IDENTITY_TOKEN_FILE', + 'ANTHROPIC_ORGANIZATION_ID', + 'ANTHROPIC_PROFILE', + ]; + + beforeEach(() => { + for (const name of envVars) { + originalEnv[name] = process.env[name]; + delete process.env[name]; + } + testDir = mkdtempSync(path.join(tmpdir(), 'client-creds-test-')); + }); + + afterEach(() => { + for (const [key, value] of Object.entries(originalEnv)) { + if (value !== undefined) { + process.env[key] = value; + } else { + delete process.env[key]; + } + } + fs.rmSync(testDir, { recursive: true }); + }); + + it('ANTHROPIC_API_KEY env var shadows a profile-configured federation config', async () => { + process.env['ANTHROPIC_CONFIG_DIR'] = testDir; + process.env['ANTHROPIC_API_KEY'] = 'sk-env-key'; + const tokenPath = path.join(testDir, 'id-token'); + fs.mkdirSync(path.join(testDir, 'configs'), { recursive: true }); + fs.writeFileSync(tokenPath, 'my-jwt'); + fs.writeFileSync( + path.join(testDir, 'configs', 'default.json'), + JSON.stringify({ + organization_id: 'org-123', + authentication: { + type: 'oidc_federation', + federation_rule_id: 'fdrl_01abc', + identity_token: { source: 'file', path: tokenPath }, + }, + }), + ); + + let exchanged = false; + const client = new Anthropic({ + fetch: async (url, init) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + if (urlStr.includes('/v1/oauth/token')) { + exchanged = true; + return jsonResponse({ access_token: 'should-not-happen', expires_in: 3600 }); + } + expect(getHeader(init, 'x-api-key')).toBe('sk-env-key'); + expect(getHeader(init, 'authorization')).toBeNull(); + return jsonResponse(VALID_MSG_RESPONSE); + }, + }); + + await client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + expect(exchanged).toBe(false); + expect(client.credentials).toBeNull(); // lazy resolution never started + }); + + it('apiKey takes precedence over config; config is not resolved', async () => { + // user_oauth without credentials_path would throw if resolved. + const client = new Anthropic({ + apiKey: 'sk-test', + config: { workspace_id: 'ws-ignored', authentication: { type: 'user_oauth' } }, + fetch: async (_url, init) => { + expect(getHeader(init, 'x-api-key')).toBe('sk-test'); + expect(getHeader(init, 'anthropic-workspace-id')).toBeNull(); + expect(getHeader(init, 'anthropic-beta')).toBeNull(); + return jsonResponse(VALID_MSG_RESPONSE); + }, + }); + + expect(client.credentials).toBeNull(); + await client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + }); + + it('apiKey takes precedence over credentials', async () => { + let providerCalled = false; + const client = new Anthropic({ + apiKey: 'sk-test', + credentials: async () => { + providerCalled = true; + return { token: 'should-not-be-used', expiresAt: null }; + }, + fetch: async (_url, init) => { + expect(getHeader(init, 'x-api-key')).toBe('sk-test'); + expect(getHeader(init, 'authorization')).toBeNull(); + return jsonResponse(VALID_MSG_RESPONSE); + }, + }); + + await client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + expect(providerCalled).toBe(false); + }); + + it('uses explicit credentials provider for bearer auth', async () => { + const client = new Anthropic({ + apiKey: null, + credentials: async () => ({ token: 'my-access-token', expiresAt: farFuture() }), + fetch: async (_url, init) => { + expect(getHeader(init, 'authorization')).toBe('Bearer my-access-token'); + expect(getHeader(init, 'anthropic-beta')).toContain(OAUTH_API_BETA_HEADER); + return jsonResponse(VALID_MSG_RESPONSE); + }, + }); + + await client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + }); + + it('lazily resolves credentials from env vars on first request', async () => { + const tokenPath = path.join(testDir, 'id-token'); + fs.writeFileSync(tokenPath, 'my-jwt'); + + process.env['ANTHROPIC_CONFIG_DIR'] = path.join(testDir, 'nonexistent'); + process.env['ANTHROPIC_FEDERATION_RULE_ID'] = 'fdrl_test'; + process.env['ANTHROPIC_ORGANIZATION_ID'] = 'org-test'; + process.env['ANTHROPIC_IDENTITY_TOKEN_FILE'] = tokenPath; + + let tokenExchanged = false; + const client = new Anthropic({ + fetch: async (url, init) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + if (urlStr.includes('/v1/oauth/token')) { + tokenExchanged = true; + // The token-exchange UA matches the client's API-request UA + expect(getHeader(init, 'User-Agent')).toMatch(/^Anthropic\/JS /); + return jsonResponse({ access_token: 'resolved-tok', expires_in: 3600 }); + } + expect(getHeader(init, 'authorization')).toBe('Bearer resolved-tok'); + return jsonResponse(VALID_MSG_RESPONSE); + }, + }); + + await client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + expect(tokenExchanged).toBe(true); + }); + + it('retries on 401 after invalidating token cache', async () => { + let callCount = 0; + const client = new Anthropic({ + apiKey: null, + maxRetries: 1, + credentials: async () => { + callCount++; + return { token: `tok-${callCount}`, expiresAt: farFuture() }; + }, + fetch: async (_url, init) => { + if (getHeader(init, 'authorization') === 'Bearer tok-1') { + return jsonResponse({ error: { type: 'authentication_error', message: 'expired' } }, 401); + } + return jsonResponse(VALID_MSG_RESPONSE); + }, + }); + + await client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + expect(callCount).toBe(2); + }); + + it('caps 401-triggered refresh to once per request', async () => { + let providerCalls = 0; + const client = new Anthropic({ + apiKey: null, + maxRetries: 5, + credentials: async () => { + providerCalls++; + return { token: `tok-${providerCalls}`, expiresAt: farFuture() }; + }, + // Always 401 — refreshed token is also rejected + fetch: async () => jsonResponse({ error: { type: 'authentication_error', message: 'nope' } }, 401), + }); + + await expect( + client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }), + ).rejects.toThrow(); + // 1 initial + 1 refresh-retry. Further 401s do NOT trigger more refreshes. + expect(providerCalls).toBe(2); + }); + + it('does not refresh on 401 when apiKey was used', async () => { + let providerCalls = 0; + const client = new Anthropic({ + apiKey: 'sk-test', + maxRetries: 1, + credentials: async () => { + providerCalls++; + return { token: 'unused', expiresAt: farFuture() }; + }, + fetch: async () => jsonResponse({ error: { type: 'authentication_error', message: 'nope' } }, 401), + }); + + await expect( + client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }), + ).rejects.toThrow(); + expect(providerCalls).toBe(0); + }); + + it('surfaces lazy credential resolution errors on first request without unhandled rejection', async () => { + process.env['ANTHROPIC_CONFIG_DIR'] = testDir; + fs.mkdirSync(path.join(testDir, 'configs'), { recursive: true }); + // Config with unknown auth type → loadConfig() throws + fs.writeFileSync( + path.join(testDir, 'configs', 'default.json'), + JSON.stringify({ authentication: { type: 'mystery' } }), + ); + + const rejections: unknown[] = []; + const onUnhandled = (err: unknown) => rejections.push(err); + process.on('unhandledRejection', onUnhandled); + try { + const client = new Anthropic({ fetch: async () => jsonResponse({}) }); + // Give the eager resolution promise a tick to settle + await new Promise((r) => setImmediate(r)); + expect(rejections).toEqual([]); + + // Error surfaces on first request with the root cause, not the generic message + await expect( + client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }), + ).rejects.toThrow('not a known authentication type'); + } finally { + process.off('unhandledRejection', onUnhandled); + } + }); + + it('throws when no auth can be resolved', async () => { + process.env['ANTHROPIC_CONFIG_DIR'] = path.join(testDir, 'empty'); + fs.mkdirSync(path.join(testDir, 'empty'), { recursive: true }); + + const client = new Anthropic({ + fetch: async () => jsonResponse({}), + }); + + await expect( + client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }), + ).rejects.toThrow('Could not resolve authentication method'); + }); + + it('resolves credentials from explicit config option (oidc_federation)', async () => { + const tokenPath = path.join(testDir, 'id-token'); + fs.writeFileSync(tokenPath, 'explicit-jwt'); + + let exchanged = false; + const client = new Anthropic({ + apiKey: null, + config: { + organization_id: 'org-explicit', + authentication: { + type: 'oidc_federation', + federation_rule_id: 'fdrl_explicit', + identity_token: { source: 'file', path: tokenPath }, + }, + }, + fetch: async (url, init) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + if (urlStr.includes('/v1/oauth/token')) { + exchanged = true; + const body = JSON.parse(init!.body as string); + expect(body.federation_rule_id).toBe('fdrl_explicit'); + expect(body.assertion).toBe('explicit-jwt'); + return jsonResponse({ access_token: 'explicit-tok', expires_in: 3600 }); + } + expect(getHeader(init, 'authorization')).toBe('Bearer explicit-tok'); + return jsonResponse(VALID_MSG_RESPONSE); + }, + }); + + await client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + expect(exchanged).toBe(true); + }); + + it('explicit credentials option takes precedence over config option', async () => { + const client = new Anthropic({ + apiKey: null, + credentials: async () => ({ token: 'from-provider', expiresAt: farFuture() }), + config: { + organization_id: 'ignored', + authentication: { type: 'oidc_federation', federation_rule_id: 'ignored' }, + }, + fetch: async (_url, init) => { + expect(getHeader(init, 'authorization')).toBe('Bearer from-provider'); + return jsonResponse(VALID_MSG_RESPONSE); + }, + }); + + await client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + }); + + it('throws descriptive error when config option is incomplete', () => { + expect( + () => + new Anthropic({ + apiKey: null, + config: { authentication: { type: 'user_oauth' } }, // no credentials_path + }), + ).toThrow('user_oauth config requires authentication.credentials_path'); + + expect( + () => + new Anthropic({ + apiKey: null, + config: { + organization_id: 'org-x', + authentication: { type: 'oidc_federation', federation_rule_id: 'fdrl_x' }, + }, // no identity_token + }), + ).toThrow('requires an identity token'); + }); + + it('explicit Authorization header bypasses a stored credential resolution error', async () => { + process.env['ANTHROPIC_CONFIG_DIR'] = testDir; + fs.mkdirSync(path.join(testDir, 'configs'), { recursive: true }); + fs.writeFileSync( + path.join(testDir, 'configs', 'default.json'), + JSON.stringify({ authentication: { type: 'mystery' } }), // makes resolution fail + ); + + const client = new Anthropic({ + defaultHeaders: { Authorization: 'Bearer override-tok' }, + fetch: async (_url, init) => { + expect(getHeader(init, 'authorization')).toBe('Bearer override-tok'); + return jsonResponse(VALID_MSG_RESPONSE); + }, + }); + + // Give resolution a tick to settle (and fail) + await new Promise((r) => setImmediate(r)); + + // Explicit header escape hatch wins; resolution error is not thrown + await client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + }); + + it('withOptions clone created before lazy resolution settles shares the result', async () => { + const tokenPath = path.join(testDir, 'id-token'); + fs.writeFileSync(tokenPath, 'my-jwt'); + + process.env['ANTHROPIC_CONFIG_DIR'] = path.join(testDir, 'nonexistent'); + process.env['ANTHROPIC_FEDERATION_RULE_ID'] = 'fdrl_test'; + process.env['ANTHROPIC_ORGANIZATION_ID'] = 'org-test'; + process.env['ANTHROPIC_IDENTITY_TOKEN_FILE'] = tokenPath; + + let exchangeCount = 0; + const fetchImpl = async (url: any, init?: RequestInit): Promise => { + const urlStr = typeof url === 'string' ? url : url.toString(); + if (urlStr.includes('/v1/oauth/token')) { + exchangeCount++; + return jsonResponse({ access_token: 'shared-tok', expires_in: 3600 }); + } + expect(getHeader(init, 'authorization')).toBe('Bearer shared-tok'); + return jsonResponse(VALID_MSG_RESPONSE); + }; + + const parent = new Anthropic({ fetch: fetchImpl }); + // Clone immediately, before lazy resolution has a chance to settle + const clone = parent.withOptions({ timeout: 5000 }); + + await Promise.all([ + parent.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }), + clone.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }), + ]); + + // Shared auth state → only ONE token exchange for both clients + expect(exchangeCount).toBe(1); + expect(clone.credentials).toBe(parent.credentials); + }); + + it('withOptions clone created before resolution settles receives extraHeaders (workspace_id)', async () => { + process.env['ANTHROPIC_CONFIG_DIR'] = testDir; + fs.mkdirSync(path.join(testDir, 'configs'), { recursive: true }); + fs.mkdirSync(path.join(testDir, 'credentials'), { recursive: true }); + fs.writeFileSync( + path.join(testDir, 'configs', 'default.json'), + JSON.stringify({ workspace_id: 'ws-shared', authentication: { type: 'user_oauth' } }), + ); + fs.writeFileSync( + path.join(testDir, 'credentials', 'default.json'), + JSON.stringify({ access_token: 'tok', expires_at: farFuture() }), + { mode: 0o600 }, + ); + + const seenWs: (string | null)[] = []; + const fetchImpl = async (_url: any, init?: RequestInit): Promise => { + seenWs.push(getHeader(init, 'anthropic-workspace-id')); + return jsonResponse(VALID_MSG_RESPONSE); + }; + + const parent = new Anthropic({ fetch: fetchImpl }); + const clone = parent.withOptions({ timeout: 5000 }); // before resolution settles + + for (const c of [parent, clone]) { + await c.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + } + + expect(seenWs).toEqual(['ws-shared', 'ws-shared']); + }); + + it('pins withOptions({apiKey: undefined}) and ({credentials: null}) override semantics', async () => { + process.env['ANTHROPIC_CONFIG_DIR'] = path.join(testDir, 'empty'); + fs.mkdirSync(path.join(testDir, 'empty'), { recursive: true }); + + const seen: string[] = []; + const parent = new Anthropic({ + apiKey: null, + credentials: async () => ({ token: 'parent', expiresAt: farFuture() }), + fetch: async (_url, init) => { + seen.push(getHeader(init, 'authorization') ?? ''); + return jsonResponse(VALID_MSG_RESPONSE); + }, + }); + + // apiKey: undefined → overridesAuth=true (key is present), so the clone + // builds a fresh _auth with its OWN TokenCache wrapping the inherited + // provider (passed via the credentials: this.credentials spread). The + // clone authenticates with the same token but does not share the parent's + // cache. + const reset1 = parent.withOptions({ apiKey: undefined }); + await reset1.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + expect(seen).toEqual(['Bearer parent']); + + // credentials: null → overridesAuth=true AND options.credentials=null + // overrides the spread, so the clone has no provider and (with an empty + // config dir) hits the "could not resolve" error. + const reset2 = parent.withOptions({ credentials: null }); + await expect( + reset2.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }), + ).rejects.toThrow('Could not resolve authentication method'); + }); + + it('withOptions clone observes parent resolution failure', async () => { + process.env['ANTHROPIC_CONFIG_DIR'] = testDir; + fs.mkdirSync(path.join(testDir, 'configs'), { recursive: true }); + fs.writeFileSync( + path.join(testDir, 'configs', 'default.json'), + JSON.stringify({ authentication: { type: 'mystery' } }), + ); + + const parent = new Anthropic({ fetch: async () => jsonResponse({}) }); + const clone = parent.withOptions({ timeout: 5000 }); + await new Promise((r) => setImmediate(r)); + + await expect( + clone.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }), + ).rejects.toThrow('not a known authentication type'); + }); + + it('withOptions with explicit auth override does NOT share parent auth state', async () => { + const parent = new Anthropic({ + apiKey: null, + credentials: async () => ({ token: 'parent-tok', expiresAt: farFuture() }), + fetch: async () => jsonResponse(VALID_MSG_RESPONSE), + }); + const clone = parent.withOptions({ + credentials: async () => ({ token: 'clone-tok', expiresAt: farFuture() }), + }); + + expect(clone.credentials).not.toBe(parent.credentials); + }); + + it('nested withOptions: auth override on second hop is honored', async () => { + const seenAuths: string[] = []; + const fetchImpl = async (_url: any, init?: RequestInit): Promise => { + seenAuths.push(getHeader(init, 'authorization') ?? `apikey:${getHeader(init, 'x-api-key')}`); + return jsonResponse(VALID_MSG_RESPONSE); + }; + + const a = new Anthropic({ + apiKey: null, + credentials: async () => ({ token: 'A', expiresAt: farFuture() }), + fetch: fetchImpl, + }); + const b = a.withOptions({ timeout: 1000 }); // shares a's auth + const c = b.withOptions({ credentials: async () => ({ token: 'C', expiresAt: farFuture() }) }); // override + const d = b.withOptions({ apiKey: 'sk-d' }); // override with apiKey + + for (const client of [a, b, c, d]) { + await client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + } + + expect(seenAuths).toEqual(['Bearer A', 'Bearer A', 'Bearer C', 'apikey:sk-d']); + }); + + it('401-refresh-once flag resets between top-level requests sharing options object', async () => { + let providerCalls = 0; + let fetchCalls = 0; + const client = new Anthropic({ + apiKey: null, + maxRetries: 5, + credentials: async () => { + providerCalls++; + return { token: `tok-${providerCalls}`, expiresAt: farFuture() }; + }, + fetch: async () => { + fetchCalls++; + // First two calls 401 (initial + refresh-retry), then succeed + if (fetchCalls <= 2) { + return jsonResponse({ error: { type: 'authentication_error', message: 'nope' } }, 401); + } + return jsonResponse(VALID_MSG_RESPONSE); + }, + }); + + const sharedOpts = { + method: 'post' as const, + path: '/v1/messages', + body: { model: 'claude-sonnet-4-20250514', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }, + }; + + // First request: 401 → refresh → 401 → fail (didRefreshFor401 set) + await expect(client.request(sharedOpts)).rejects.toThrow(); + expect(providerCalls).toBe(2); + + // Second request with the SAME options object: flag must be reset so the + // 401-refresh fires again (1 more provider call). If the flag persisted, + // providerCalls would stay at 2. + fetchCalls = 0; + await expect(client.request(sharedOpts)).rejects.toThrow(); + expect(providerCalls).toBe(3); + }); + + it('withOptions propagates credentials to new client', async () => { + const provider = async (): Promise => ({ + token: 'propagated-tok', + expiresAt: farFuture(), + }); + + const client = new Anthropic({ + apiKey: null, + credentials: provider, + fetch: async () => jsonResponse(VALID_MSG_RESPONSE), + }); + + const copied = client.withOptions({ timeout: 5000 }); + expect(copied.credentials).toBe(provider); + }); + + describe('profile option', () => { + function writeProfile(name: string, opts: { base_url?: string } = {}) { + fs.mkdirSync(path.join(testDir, 'configs'), { recursive: true }); + fs.mkdirSync(path.join(testDir, 'credentials'), { recursive: true }); + fs.writeFileSync( + path.join(testDir, 'configs', `${name}.json`), + JSON.stringify({ ...opts, authentication: { type: 'user_oauth' } }), + ); + fs.writeFileSync( + path.join(testDir, 'credentials', `${name}.json`), + JSON.stringify({ access_token: `${name}-tok`, expires_at: farFuture() }), + { mode: 0o600 }, + ); + } + + beforeEach(() => { + process.env['ANTHROPIC_CONFIG_DIR'] = testDir; + }); + + it('loads the named profile', async () => { + writeProfile('staging'); + const client = new Anthropic({ + profile: 'staging', + fetch: async (_url, init) => { + expect(getHeader(init, 'authorization')).toBe('Bearer staging-tok'); + expect(getHeader(init, 'x-api-key')).toBeNull(); + return jsonResponse(VALID_MSG_RESPONSE); + }, + }); + await client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + expect(client.credentials).not.toBeNull(); + }); + + it('beats ANTHROPIC_API_KEY in env', async () => { + process.env['ANTHROPIC_API_KEY'] = 'sk-env-should-be-ignored'; + writeProfile('staging'); + const client = new Anthropic({ + profile: 'staging', + fetch: async (_url, init) => { + expect(getHeader(init, 'authorization')).toBe('Bearer staging-tok'); + expect(getHeader(init, 'x-api-key')).toBeNull(); + return jsonResponse(VALID_MSG_RESPONSE); + }, + }); + expect(client.apiKey).toBeNull(); + await client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + }); + + it('beats ANTHROPIC_PROFILE in env', async () => { + process.env['ANTHROPIC_PROFILE'] = 'from-env'; + writeProfile('from-env'); + writeProfile('from-ctor'); + const client = new Anthropic({ + profile: 'from-ctor', + fetch: async (_url, init) => { + expect(getHeader(init, 'authorization')).toBe('Bearer from-ctor-tok'); + return jsonResponse(VALID_MSG_RESPONSE); + }, + }); + await client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + }); + + it('throws when combined with credentials', () => { + expect( + () => + new Anthropic({ + profile: 'staging', + credentials: async () => ({ token: 'x', expiresAt: null }), + }), + ).toThrow(/Pass at most one of `profile`, `credentials`, or `config`/); + }); + + it('throws when combined with config', () => { + expect( + () => + new Anthropic({ + profile: 'staging', + config: { authentication: { type: 'user_oauth', credentials_path: '/dev/null' } }, + }), + ).toThrow(/Pass at most one of `profile`, `credentials`, or `config`/); + }); + + it('surfaces a clear error when the named profile does not exist', async () => { + const client = new Anthropic({ + profile: 'nope', + fetch: async () => jsonResponse(VALID_MSG_RESPONSE), + }); + await expect( + client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }), + ).rejects.toThrow(/Profile "nope" could not be resolved/); + }); + + it('withOptions({profile}) on a profile-based parent switches profile', async () => { + writeProfile('a'); + writeProfile('b'); + const parent = new Anthropic({ + profile: 'a', + fetch: async () => jsonResponse(VALID_MSG_RESPONSE), + }); + const clone = parent.withOptions({ + profile: 'b', + fetch: async (_url, init) => { + expect(getHeader(init, 'authorization')).toBe('Bearer b-tok'); + return jsonResponse(VALID_MSG_RESPONSE); + }, + }); + await clone.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + }); + }); + + describe('baseURL precedence (ctor opt > env > profile > default)', () => { + function writeUserOAuthProfile(dir: string, base_url?: string) { + fs.mkdirSync(path.join(dir, 'configs'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'credentials'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'configs', 'default.json'), + JSON.stringify({ ...(base_url ? { base_url } : {}), authentication: { type: 'user_oauth' } }), + ); + fs.writeFileSync( + path.join(dir, 'credentials', 'default.json'), + JSON.stringify({ access_token: 'tok', expires_at: farFuture() }), + { mode: 0o600 }, + ); + } + + it('explicit config.base_url propagates to client.baseURL (sync path)', () => { + const credPath = path.join(testDir, 'creds.json'); + fs.writeFileSync(credPath, JSON.stringify({ access_token: 'tok', expires_at: farFuture() }), { + mode: 0o600, + }); + const client = new Anthropic({ + apiKey: null, + config: { + base_url: 'https://staging.example.com/', + authentication: { type: 'user_oauth', credentials_path: credPath }, + }, + fetch: async () => jsonResponse(VALID_MSG_RESPONSE), + }); + expect(client.baseURL).toBe('https://staging.example.com'); + }); + + it('profile base_url propagates to outbound API host (lazy path)', async () => { + process.env['ANTHROPIC_CONFIG_DIR'] = testDir; + writeUserOAuthProfile(testDir, 'https://staging.example.com'); + + const seenHosts: string[] = []; + const client = new Anthropic({ + fetch: async (url) => { + seenHosts.push(new URL(typeof url === 'string' ? url : url.toString()).host); + return jsonResponse(VALID_MSG_RESPONSE); + }, + }); + + await client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + expect(seenHosts).toEqual(['staging.example.com']); + expect(client.baseURL).toBe('https://staging.example.com'); + }); + + it('ANTHROPIC_BASE_URL env wins over profile base_url', async () => { + process.env['ANTHROPIC_CONFIG_DIR'] = testDir; + process.env['ANTHROPIC_BASE_URL'] = 'https://env.example.com'; + writeUserOAuthProfile(testDir, 'https://profile.example.com'); + + const seenHosts: string[] = []; + const client = new Anthropic({ + fetch: async (url) => { + seenHosts.push(new URL(typeof url === 'string' ? url : url.toString()).host); + return jsonResponse(VALID_MSG_RESPONSE); + }, + }); + + await client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + expect(client.baseURL).toBe('https://env.example.com'); + expect(seenHosts).toEqual(['env.example.com']); + }); + + it('constructor baseURL opt wins over profile base_url', async () => { + process.env['ANTHROPIC_CONFIG_DIR'] = testDir; + writeUserOAuthProfile(testDir, 'https://profile.example.com'); + + const client = new Anthropic({ + baseURL: 'https://opt.example.com', + fetch: async () => jsonResponse(VALID_MSG_RESPONSE), + }); + await client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + expect(client.baseURL).toBe('https://opt.example.com'); + }); + + it('falls through to hardcoded default when profile has no base_url', async () => { + process.env['ANTHROPIC_CONFIG_DIR'] = testDir; + writeUserOAuthProfile(testDir /* no base_url */); + + const client = new Anthropic({ fetch: async () => jsonResponse(VALID_MSG_RESPONSE) }); + await client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + expect(client.baseURL).toBe('https://api.anthropic.com'); + }); + + it('withOptions clone created before lazy resolution settles adopts profile base_url', async () => { + process.env['ANTHROPIC_CONFIG_DIR'] = testDir; + writeUserOAuthProfile(testDir, 'https://staging.example.com'); + + const seenHosts: string[] = []; + const fetchImpl = async (url: any): Promise => { + seenHosts.push(new URL(typeof url === 'string' ? url : url.toString()).host); + return jsonResponse(VALID_MSG_RESPONSE); + }; + + const parent = new Anthropic({ fetch: fetchImpl }); + const clone = parent.withOptions({ timeout: 5000 }); // before resolution settles + + for (const c of [parent, clone]) { + await c.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + } + expect(seenHosts).toEqual(['staging.example.com', 'staging.example.com']); + expect(clone.baseURL).toBe('https://staging.example.com'); + }); + + it('nested withOptions chain on a profile-bound parent stays on the profile host', async () => { + process.env['ANTHROPIC_CONFIG_DIR'] = testDir; + writeUserOAuthProfile(testDir, 'https://staging.example.com'); + + const seenHosts: string[] = []; + const fetchImpl = async (url: any): Promise => { + seenHosts.push(new URL(typeof url === 'string' ? url : url.toString()).host); + return jsonResponse(VALID_MSG_RESPONSE); + }; + + const a = new Anthropic({ fetch: fetchImpl }); + // Let resolution settle so a.baseURL has been mutated to the profile host + // before cloning — exercises the "don't pin mutated baseURL into clone + // options" path in withOptions(). + await a.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + const b = a.withOptions({ maxRetries: 5 }); + const c = b.withOptions({ maxRetries: 6 }); + + for (const client of [b, c]) { + await client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + } + expect(seenHosts).toEqual(['staging.example.com', 'staging.example.com', 'staging.example.com']); + expect(c.baseURL).toBe('https://staging.example.com'); + }); + + it('withOptions auth override does not inherit parent profile base_url', async () => { + process.env['ANTHROPIC_CONFIG_DIR'] = testDir; + writeUserOAuthProfile(testDir, 'https://staging.example.com'); + + const parent = new Anthropic({ fetch: async () => jsonResponse(VALID_MSG_RESPONSE) }); + await parent.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + expect(parent.baseURL).toBe('https://staging.example.com'); + + // Auth override → fresh _authState (no profile baseURL). Clone must NOT + // be stuck on the parent's resolved staging host; it falls back to the + // construction-time default since no caller/env/profile pinned one. + const seenHosts: string[] = []; + const clone = parent.withOptions({ + credentials: async () => ({ token: 'override-tok', expiresAt: farFuture() }), + fetch: async (url) => { + seenHosts.push(new URL(typeof url === 'string' ? url : url.toString()).host); + return jsonResponse(VALID_MSG_RESPONSE); + }, + }); + await clone.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + expect(clone.baseURL).toBe('https://api.anthropic.com'); + expect(seenHosts).toEqual(['api.anthropic.com']); + }); + + it('withOptions({baseURL}) override is honored over profile base_url', async () => { + process.env['ANTHROPIC_CONFIG_DIR'] = testDir; + writeUserOAuthProfile(testDir, 'https://staging.example.com'); + + const parent = new Anthropic({ fetch: async () => jsonResponse(VALID_MSG_RESPONSE) }); + const clone = parent.withOptions({ baseURL: 'https://override.example.com' }); + + await clone.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + expect(clone.baseURL).toBe('https://override.example.com'); + }); + }); +}); diff --git a/tests/lib/credentials/credential-chain.test.ts b/tests/lib/credentials/credential-chain.test.ts new file mode 100644 index 00000000..77893a8f --- /dev/null +++ b/tests/lib/credentials/credential-chain.test.ts @@ -0,0 +1,446 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import type { Fetch } from '@anthropic-ai/sdk/internal/builtin-types'; +import { defaultCredentials } from '@anthropic-ai/sdk/lib/credentials/credential-chain'; + +const NOW_IN_SECONDS = 1700000000; + +beforeAll(() => { + jest.useFakeTimers({ now: NOW_IN_SECONDS * 1000 }); +}); + +afterAll(() => { + jest.useRealTimers(); +}); + +function jsonResponse(body: object, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('defaultCredentials', () => { + let testDir: string; + const originalEnv: Record = {}; + + const envVars = [ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_CONFIG_DIR', + 'ANTHROPIC_FEDERATION_RULE_ID', + 'ANTHROPIC_IDENTITY_TOKEN', + 'ANTHROPIC_IDENTITY_TOKEN_FILE', + 'ANTHROPIC_ORGANIZATION_ID', + 'ANTHROPIC_PROFILE', + 'ANTHROPIC_SCOPE', + 'ANTHROPIC_SERVICE_ACCOUNT_ID', + 'APPDATA', + 'HOME', + 'XDG_CONFIG_HOME', + ]; + + beforeEach(() => { + for (const name of envVars) { + originalEnv[name] = process.env[name]; + } + testDir = mkdtempSync(path.join(tmpdir(), 'chain-test-')); + process.env['ANTHROPIC_CONFIG_DIR'] = testDir; + }); + + afterEach(() => { + for (const [key, value] of Object.entries(originalEnv)) { + if (value !== undefined) { + process.env[key] = value; + } else { + delete process.env[key]; + } + } + fs.rmSync(testDir, { recursive: true }); + }); + + const baseOptions = { + baseURL: 'https://api.anthropic.com', + fetch: jest.fn() as unknown as Fetch, + }; + + function writeConfig(profile: string, config: object) { + const dir = path.join(testDir, 'configs'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, `${profile}.json`), JSON.stringify(config)); + } + + function writeCredentials(profile: string, creds: object) { + const dir = path.join(testDir, 'credentials'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, `${profile}.json`), JSON.stringify(creds), { mode: 0o600 }); + } + + it('returns null when no config or env vars are set', async () => { + expect(await defaultCredentials(baseOptions)).toBeNull(); + }); + + it('resolves user_oauth with fresh cached token', async () => { + writeConfig('default', { authentication: { type: 'user_oauth', client_id: 'my-client' } }); + writeCredentials('default', { + access_token: 'user-tok', + refresh_token: 'refresh-tok', + expires_at: NOW_IN_SECONDS + 3600, + }); + + const result = await defaultCredentials(baseOptions); + expect(result).not.toBeNull(); + + const token = await result!.provider(); + expect(token.token).toBe('user-tok'); + }); + + it('includes workspace_id header for user_oauth only', async () => { + writeConfig('default', { + workspace_id: 'ws-user', + authentication: { type: 'user_oauth' }, + }); + writeCredentials('default', { + access_token: 'tok', + refresh_token: 'ref', + expires_at: NOW_IN_SECONDS + 3600, + }); + const userResult = await defaultCredentials(baseOptions); + expect(userResult!.extraHeaders).toEqual({ 'anthropic-workspace-id': 'ws-user' }); + + const tokenPath = path.join(testDir, 'id-token'); + fs.writeFileSync(tokenPath, 'my-jwt'); + writeConfig('default', { + workspace_id: 'ws-fed', + organization_id: 'org-123', + authentication: { + type: 'oidc_federation', + federation_rule_id: 'fdrl_01abc', + identity_token: { source: 'file', path: tokenPath }, + }, + }); + const fedResult = await defaultCredentials({ + ...baseOptions, + fetch: jest.fn().mockResolvedValue(jsonResponse({ access_token: 'tok', expires_in: 60 })), + }); + // Federation tokens are workspace-scoped at issue time → header omitted + expect(fedResult!.extraHeaders).toEqual({}); + }); + + it('resolves oidc_federation from config with identity_token file', async () => { + const tokenPath = path.join(testDir, 'id-token'); + fs.writeFileSync(tokenPath, 'my-jwt'); + + writeConfig('default', { + organization_id: 'org-123', + authentication: { + type: 'oidc_federation', + federation_rule_id: 'fdrl_01abc', + identity_token: { source: 'file', path: tokenPath }, + }, + }); + + const mockFetch: Fetch = jest + .fn() + .mockResolvedValue(jsonResponse({ access_token: 'exchanged-tok', expires_in: 3600 })); + + const result = await defaultCredentials({ ...baseOptions, fetch: mockFetch }); + expect(result).not.toBeNull(); + + const token = await result!.provider(); + expect(token.token).toBe('exchanged-tok'); + }); + + it('uses cached credential file for oidc_federation before exchanging', async () => { + const tokenPath = path.join(testDir, 'id-token'); + fs.writeFileSync(tokenPath, 'my-jwt'); + + writeConfig('default', { + organization_id: 'org-123', + authentication: { + type: 'oidc_federation', + federation_rule_id: 'fdrl_01abc', + identity_token: { source: 'file', path: tokenPath }, + }, + }); + writeCredentials('default', { + access_token: 'cached-tok', + expires_at: NOW_IN_SECONDS + 3600, + }); + + const mockFetch: Fetch = jest.fn(); + + const result = await defaultCredentials({ ...baseOptions, fetch: mockFetch }); + expect(result).not.toBeNull(); + + const token = await result!.provider(); + expect(token.token).toBe('cached-tok'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('preserves unknown keys (e.g. refresh_token) when writing federation cache', async () => { + const tokenPath = path.join(testDir, 'id-token'); + fs.writeFileSync(tokenPath, 'my-jwt'); + + writeConfig('default', { + organization_id: 'org-123', + authentication: { + type: 'oidc_federation', + federation_rule_id: 'fdrl_01abc', + identity_token: { source: 'file', path: tokenPath }, + }, + }); + // Stale cache with a refresh_token that the federation writer must not clobber + writeCredentials('default', { + access_token: 'stale', + expires_at: NOW_IN_SECONDS - 10, + refresh_token: 'preserved-rt', + custom_field: 'kept', + }); + + const mockFetch: Fetch = jest + .fn() + .mockResolvedValue(jsonResponse({ access_token: 'new-tok', expires_in: 3600 })); + + const result = await defaultCredentials({ ...baseOptions, fetch: mockFetch }); + await result!.provider(); + + const written = JSON.parse(fs.readFileSync(path.join(testDir, 'credentials', 'default.json'), 'utf-8')); + expect(written.version).toBe('1.0'); + expect(written.access_token).toBe('new-tok'); + expect(written.refresh_token).toBe('preserved-rt'); + expect(written.custom_field).toBe('kept'); + }); + + it('surfaces cache write errors to onCacheWriteError without failing the exchange', async () => { + const tokenPath = path.join(testDir, 'id-token'); + fs.writeFileSync(tokenPath, 'my-jwt'); + + writeConfig('default', { + organization_id: 'org-123', + authentication: { + type: 'oidc_federation', + federation_rule_id: 'fdrl_01abc', + identity_token: { source: 'file', path: tokenPath }, + // Point credentials_path at a directory so the write fails + credentials_path: testDir, + }, + }); + + const mockFetch: Fetch = jest + .fn() + .mockResolvedValue(jsonResponse({ access_token: 'tok', expires_in: 60 })); + const onErr = jest.fn(); + + const result = await defaultCredentials({ ...baseOptions, fetch: mockFetch, onCacheWriteError: onErr }); + const token = await result!.provider(); + + expect(token.token).toBe('tok'); // exchange still succeeds + // Both the failed cache read (EISDIR) and the failed write surface here + expect(onErr).toHaveBeenCalled(); + expect((onErr.mock.calls.at(-1)![0] as NodeJS.ErrnoException).code).toBe('EISDIR'); + }); + + it('threads userAgent through to the federation exchange', async () => { + const tokenPath = path.join(testDir, 'id-token'); + fs.writeFileSync(tokenPath, 'my-jwt'); + + writeConfig('default', { + organization_id: 'org-123', + authentication: { + type: 'oidc_federation', + federation_rule_id: 'fdrl_01abc', + identity_token: { source: 'file', path: tokenPath }, + }, + }); + + const mockFetch: Fetch = jest + .fn() + .mockResolvedValue(jsonResponse({ access_token: 'tok', expires_in: 60 })); + + const result = await defaultCredentials({ ...baseOptions, fetch: mockFetch, userAgent: 'ant-cli/1.2.3' }); + await result!.provider(); + + expect((mockFetch as jest.Mock).mock.calls[0]![1].headers['User-Agent']).toBe('ant-cli/1.2.3'); + }); + + it('writes back credential file after oidc_federation exchange', async () => { + const tokenPath = path.join(testDir, 'id-token'); + fs.writeFileSync(tokenPath, 'my-jwt'); + + writeConfig('default', { + organization_id: 'org-123', + authentication: { + type: 'oidc_federation', + federation_rule_id: 'fdrl_01abc', + identity_token: { source: 'file', path: tokenPath }, + }, + }); + // No cached credentials. Will exchange + + const mockFetch: Fetch = jest + .fn() + .mockResolvedValue(jsonResponse({ access_token: 'new-tok', expires_in: 3600 })); + + const result = await defaultCredentials({ ...baseOptions, fetch: mockFetch }); + const token = await result!.provider(); + expect(token.token).toBe('new-tok'); + + // Check that credentials were written back + const credPath = path.join(testDir, 'credentials', 'default.json'); + const written = JSON.parse(fs.readFileSync(credPath, 'utf-8')); + expect(written.access_token).toBe('new-tok'); + expect(written.expires_at).toBe(NOW_IN_SECONDS + 3600); + }); + + it('resolves oidc_federation from env vars with identity token file', async () => { + delete process.env['ANTHROPIC_CONFIG_DIR']; + process.env['ANTHROPIC_CONFIG_DIR'] = path.join(testDir, 'nonexistent'); + + const tokenPath = path.join(testDir, 'id-token'); + fs.writeFileSync(tokenPath, 'env-jwt'); + + process.env['ANTHROPIC_FEDERATION_RULE_ID'] = 'fdrl_env'; + process.env['ANTHROPIC_ORGANIZATION_ID'] = 'org-env'; + process.env['ANTHROPIC_IDENTITY_TOKEN_FILE'] = tokenPath; + + const mockFetch: Fetch = jest + .fn() + .mockResolvedValue(jsonResponse({ access_token: 'env-tok', expires_in: 60 })); + + const result = await defaultCredentials({ ...baseOptions, fetch: mockFetch }); + expect(result).not.toBeNull(); + + const token = await result!.provider(); + expect(token.token).toBe('env-tok'); + }); + + it('resolves oidc_federation from env vars with static identity token', async () => { + delete process.env['ANTHROPIC_CONFIG_DIR']; + process.env['ANTHROPIC_CONFIG_DIR'] = path.join(testDir, 'nonexistent'); + + process.env['ANTHROPIC_FEDERATION_RULE_ID'] = 'fdrl_env'; + process.env['ANTHROPIC_ORGANIZATION_ID'] = 'org-env'; + process.env['ANTHROPIC_IDENTITY_TOKEN'] = 'static-jwt'; + + const mockFetch: Fetch = jest + .fn() + .mockResolvedValue(jsonResponse({ access_token: 'static-tok', expires_in: 60 })); + + const result = await defaultCredentials({ ...baseOptions, fetch: mockFetch }); + expect(result).not.toBeNull(); + + const token = await result!.provider(); + expect(token.token).toBe('static-tok'); + + const body = JSON.parse((mockFetch as jest.Mock).mock.calls[0]![1].body); + expect(body.assertion).toBe('static-jwt'); + }); + + it('throws clear error for oidc_federation env vars without identity token', async () => { + delete process.env['ANTHROPIC_CONFIG_DIR']; + process.env['ANTHROPIC_CONFIG_DIR'] = path.join(testDir, 'nonexistent'); + + process.env['ANTHROPIC_FEDERATION_RULE_ID'] = 'fdrl_env'; + process.env['ANTHROPIC_ORGANIZATION_ID'] = 'org-env'; + // No ANTHROPIC_IDENTITY_TOKEN or ANTHROPIC_IDENTITY_TOKEN_FILE + + await expect(defaultCredentials(baseOptions)).rejects.toThrow('requires an identity token'); + }); + + it('uses base_url from config for token exchange', async () => { + const tokenPath = path.join(testDir, 'id-token'); + fs.writeFileSync(tokenPath, 'my-jwt'); + + writeConfig('default', { + base_url: 'https://custom-api.example.com', + organization_id: 'org-123', + authentication: { + type: 'oidc_federation', + federation_rule_id: 'fdrl_01abc', + identity_token: { source: 'file', path: tokenPath }, + }, + }); + + const mockFetch: Fetch = jest + .fn() + .mockResolvedValue(jsonResponse({ access_token: 'tok', expires_in: 60 })); + + const result = await defaultCredentials({ ...baseOptions, fetch: mockFetch }); + await result!.provider(); + + const [url] = (mockFetch as jest.Mock).mock.calls[0]!; + expect(url).toBe('https://custom-api.example.com/v1/oauth/token'); + }); + + it('throws clear error when oidc_federation config has no organization_id', async () => { + const tokenPath = path.join(testDir, 'id-token'); + fs.writeFileSync(tokenPath, 'my-jwt'); + + writeConfig('default', { + authentication: { + type: 'oidc_federation', + federation_rule_id: 'fdrl_01abc', + identity_token: { source: 'file', path: tokenPath }, + }, + }); + + await expect(defaultCredentials(baseOptions)).rejects.toThrow('requires organization_id'); + }); + + it('throws clear error when oidc_federation config has no federation_rule_id', async () => { + const tokenPath = path.join(testDir, 'id-token'); + fs.writeFileSync(tokenPath, 'my-jwt'); + + writeConfig('default', { + organization_id: 'org_123', + authentication: { + type: 'oidc_federation', + identity_token: { source: 'file', path: tokenPath }, + }, + }); + + await expect(defaultCredentials(baseOptions)).rejects.toThrow(/requires 'federation_rule_id'/); + }); + + it('rejects identity_token with an unknown source', async () => { + writeConfig('default', { + organization_id: 'org_123', + authentication: { + type: 'oidc_federation', + federation_rule_id: 'fdrl_01abc', + identity_token: { source: 'gcp_metadata', audience: 'anthropic' }, + }, + }); + + await expect(defaultCredentials(baseOptions)).rejects.toThrow( + /identity_token.source "gcp_metadata" is not supported/, + ); + }); + + it('rejects identity_token.source "file" with empty path', async () => { + writeConfig('default', { + organization_id: 'org_123', + authentication: { + type: 'oidc_federation', + federation_rule_id: 'fdrl_01abc', + identity_token: { source: 'file', path: '' }, + }, + }); + + await expect(defaultCredentials(baseOptions)).rejects.toThrow(/requires a non-empty path/); + }); + + it('uses ANTHROPIC_PROFILE to select config', async () => { + process.env['ANTHROPIC_PROFILE'] = 'staging'; + writeConfig('staging', { authentication: { type: 'user_oauth' } }); + writeCredentials('staging', { access_token: 'staging-tok' }); + + const result = await defaultCredentials(baseOptions); + expect(result).not.toBeNull(); + + const token = await result!.provider(); + expect(token.token).toBe('staging-tok'); + }); +}); diff --git a/tests/lib/credentials/identity-token.test.ts b/tests/lib/credentials/identity-token.test.ts new file mode 100644 index 00000000..0de74564 --- /dev/null +++ b/tests/lib/credentials/identity-token.test.ts @@ -0,0 +1,67 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { + identityTokenFromFile, + identityTokenFromValue, +} from '@anthropic-ai/sdk/lib/credentials/identity-token'; + +describe('identityTokenFromFile', () => { + let testDir: string; + + beforeEach(() => { + testDir = mkdtempSync(path.join(tmpdir(), 'identity-token-test-')); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true }); + }); + + it('reads and trims the token from a file', async () => { + const tokenPath = path.join(testDir, 'token'); + fs.writeFileSync(tokenPath, ' my-jwt-token\n'); + + const provider = identityTokenFromFile(tokenPath); + expect(await provider()).toBe('my-jwt-token'); + }); + + it('re-reads the file on every call (supports rotation)', async () => { + const tokenPath = path.join(testDir, 'token'); + fs.writeFileSync(tokenPath, 'token-v1'); + + const provider = identityTokenFromFile(tokenPath); + expect(await provider()).toBe('token-v1'); + + fs.writeFileSync(tokenPath, 'token-v2'); + expect(await provider()).toBe('token-v2'); + }); + + it('throws if the file does not exist', async () => { + const provider = identityTokenFromFile(path.join(testDir, 'missing')); + await expect(provider()).rejects.toThrow('Failed to read identity token file'); + }); + + it('throws if the file is empty', async () => { + const tokenPath = path.join(testDir, 'token'); + fs.writeFileSync(tokenPath, ' \n'); + + const provider = identityTokenFromFile(tokenPath); + await expect(provider()).rejects.toThrow('is empty'); + }); + + it('throws immediately if path is empty', () => { + expect(() => identityTokenFromFile('')).toThrow('Identity token file path is empty'); + }); +}); + +describe('identityTokenFromValue', () => { + it('returns the static token', async () => { + const provider = identityTokenFromValue('static-jwt'); + expect(await provider()).toBe('static-jwt'); + }); + + it('throws immediately if value is empty', () => { + expect(() => identityTokenFromValue('')).toThrow('Identity token value is empty'); + }); +}); diff --git a/tests/lib/credentials/oidc-federation.test.ts b/tests/lib/credentials/oidc-federation.test.ts new file mode 100644 index 00000000..e786d768 --- /dev/null +++ b/tests/lib/credentials/oidc-federation.test.ts @@ -0,0 +1,247 @@ +import type { Fetch } from '@anthropic-ai/sdk/internal/builtin-types'; +import { oidcFederationProvider } from '@anthropic-ai/sdk/lib/credentials/oidc-federation'; +import { + WorkloadIdentityError, + OAUTH_API_BETA_HEADER, + FEDERATION_BETA_HEADER, + redactSensitive, +} from '@anthropic-ai/sdk/lib/credentials/types'; + +const NOW_IN_SECONDS = 1700000000; + +beforeAll(() => { + jest.useFakeTimers({ now: NOW_IN_SECONDS * 1000 }); +}); + +afterAll(() => { + jest.useRealTimers(); +}); + +function jsonResponse(body: object, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('oidcFederationProvider', () => { + const baseConfig = { + identityTokenProvider: () => 'my-jwt', + federationRuleId: 'fdrl_01abc', + organizationId: 'org-uuid-123', + baseURL: 'https://api.anthropic.com', + }; + + it('exchanges a JWT for an access token', async () => { + const mockFetch: Fetch = jest + .fn() + .mockResolvedValue(jsonResponse({ access_token: 'ant-at_xxx', expires_in: 3600 })); + + const provider = oidcFederationProvider({ ...baseConfig, fetch: mockFetch }); + const token = await provider(); + + expect(token.token).toBe('ant-at_xxx'); + expect(token.expiresAt).toBe(NOW_IN_SECONDS + 3600); + + const [url, init] = (mockFetch as jest.Mock).mock.calls[0]!; + expect(url).toBe('https://api.anthropic.com/v1/oauth/token'); + expect(init.method).toBe('POST'); + expect(init.headers['anthropic-beta']).toBe(`${OAUTH_API_BETA_HEADER},${FEDERATION_BETA_HEADER}`); + expect(init.headers['User-Agent']).toMatch(/^anthropic-sdk-typescript\//); + + const body = JSON.parse(init.body); + expect(body.grant_type).toBe('urn:ietf:params:oauth:grant-type:jwt-bearer'); + expect(body.assertion).toBe('my-jwt'); + expect(body.federation_rule_id).toBe('fdrl_01abc'); + expect(body.organization_id).toBe('org-uuid-123'); + }); + + it('includes service_account_id when provided and never sends scope', async () => { + const mockFetch: Fetch = jest + .fn() + .mockResolvedValue(jsonResponse({ access_token: 'tok', expires_in: 60 })); + + const provider = oidcFederationProvider({ + ...baseConfig, + fetch: mockFetch, + serviceAccountId: 'svac_01abc', + }); + await provider(); + + const body = JSON.parse((mockFetch as jest.Mock).mock.calls[0]![1].body); + expect(body.service_account_id).toBe('svac_01abc'); + expect(body).not.toHaveProperty('scope'); + }); + + it('uses custom userAgent when provided', async () => { + const mockFetch: Fetch = jest + .fn() + .mockResolvedValue(jsonResponse({ access_token: 'tok', expires_in: 60 })); + + const provider = oidcFederationProvider({ + ...baseConfig, + fetch: mockFetch, + userAgent: 'ant-cli/1.2.3', + }); + await provider(); + + const headers = (mockFetch as jest.Mock).mock.calls[0]![1].headers; + expect(headers['User-Agent']).toBe('ant-cli/1.2.3'); + }); + + it('throws WorkloadIdentityError with requestId on non-ok response', async () => { + const mockFetch: Fetch = jest.fn().mockResolvedValue( + new Response('unauthorized', { + status: 401, + headers: { 'Request-Id': 'req_123' }, + }), + ); + + const provider = oidcFederationProvider({ ...baseConfig, fetch: mockFetch }); + + await expect(provider()).rejects.toThrow(WorkloadIdentityError); + try { + await provider(); + fail('expected throw'); + } catch (err) { + expect((err as WorkloadIdentityError).statusCode).toBe(401); + expect((err as WorkloadIdentityError).requestId).toBe('req_123'); + } + }); + + it('throws WorkloadIdentityError on missing access_token', async () => { + const mockFetch: Fetch = jest.fn().mockResolvedValue(jsonResponse({ expires_in: 3600 })); + + const provider = oidcFederationProvider({ ...baseConfig, fetch: mockFetch }); + await expect(provider()).rejects.toThrow('missing access_token'); + }); + + it('rejects non-Bearer token_type', async () => { + const mockFetch: Fetch = jest + .fn() + .mockResolvedValue(jsonResponse({ access_token: 'tok', token_type: 'mac', expires_in: 60 })); + + const provider = oidcFederationProvider({ ...baseConfig, fetch: mockFetch }); + await expect(provider()).rejects.toThrow('unsupported token_type "mac"'); + }); + + it('accepts Bearer token_type case-insensitively', async () => { + const mockFetch: Fetch = jest + .fn() + .mockResolvedValue(jsonResponse({ access_token: 'tok', token_type: 'bearer', expires_in: 60 })); + + const provider = oidcFederationProvider({ ...baseConfig, fetch: mockFetch }); + const token = await provider(); + expect(token.token).toBe('tok'); + }); + + it('rejects non-https token endpoint (allows loopback http)', async () => { + const provider = oidcFederationProvider({ + ...baseConfig, + baseURL: 'http://api.example.com', + fetch: jest.fn(), + }); + await expect(provider()).rejects.toThrow('non-https token endpoint'); + + for (const baseURL of ['http://localhost:8080', 'http://127.0.0.1', 'http://[::1]:8080']) { + const loopback = oidcFederationProvider({ + ...baseConfig, + baseURL, + fetch: jest.fn().mockResolvedValue(jsonResponse({ access_token: 'tok', expires_in: 60 })), + }); + await expect(loopback()).resolves.toEqual({ token: 'tok', expiresAt: NOW_IN_SECONDS + 60 }); + } + }); + + it('rejects identity tokens larger than 16 KiB', async () => { + const provider = oidcFederationProvider({ + ...baseConfig, + identityTokenProvider: () => 'x'.repeat(17 * 1024), + fetch: jest.fn(), + }); + await expect(provider()).rejects.toThrow('exceeds the 16 KiB assertion limit'); + }); + + it('omits empty-string serviceAccountId from request body', async () => { + const mockFetch: Fetch = jest + .fn() + .mockResolvedValue(jsonResponse({ access_token: 'tok', expires_in: 60 })); + + const provider = oidcFederationProvider({ ...baseConfig, fetch: mockFetch, serviceAccountId: '' }); + await provider(); + + const body = JSON.parse((mockFetch as jest.Mock).mock.calls[0]![1].body); + expect(body).not.toHaveProperty('service_account_id'); + }); + + it('redactSensitive keeps only RFC 6749 §5.2 error fields and is idempotent', () => { + const input = { + error: 'invalid_grant', + error_description: 'expired', + error_uri: 'https://x', + access_token: 'a', + assertion: 'jwt', + something_else: 'x', + }; + const snapshot = JSON.stringify(input); + const once = redactSensitive(input); + const twice = redactSensitive(once); + + expect(JSON.stringify(input)).toBe(snapshot); // input unchanged + expect(once).toEqual({ error: 'invalid_grant', error_description: 'expired', error_uri: 'https://x' }); + expect(twice).toEqual(once); // idempotent + + // Non-JSON strings are truncated, not parsed + const long = 'x'.repeat(2500); + const truncated = redactSensitive(long); + expect(typeof truncated).toBe('string'); + expect((truncated as string).length).toBeLessThan(long.length); + expect(truncated as string).toMatch(/<500 more chars>$/); + }); + + it('error bodies from the token endpoint are allowlist-redacted', async () => { + const mockFetch: Fetch = jest + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ error: 'invalid_grant', assertion: 'SECRET-JWT', refresh_token: 'SECRET-RT' }), + { status: 400 }, + ), + ); + + const provider = oidcFederationProvider({ ...baseConfig, fetch: mockFetch }); + try { + await provider(); + fail('expected throw'); + } catch (err) { + const e = err as WorkloadIdentityError; + expect(e.message).not.toContain('SECRET'); + expect(JSON.stringify(e.body)).not.toContain('SECRET'); + expect(JSON.parse(e.body as string)).toEqual({ error: 'invalid_grant' }); + } + }); + + it('throws WorkloadIdentityError on network failure', async () => { + const mockFetch: Fetch = jest.fn().mockRejectedValue(new Error('network down')); + + const provider = oidcFederationProvider({ ...baseConfig, fetch: mockFetch }); + await expect(provider()).rejects.toThrow('Failed to reach token endpoint'); + }); + + it('supports async identity token providers', async () => { + const asyncIdentity = async () => 'async-jwt'; + const mockFetch: Fetch = jest + .fn() + .mockResolvedValue(jsonResponse({ access_token: 'tok', expires_in: 60 })); + + const provider = oidcFederationProvider({ + ...baseConfig, + identityTokenProvider: asyncIdentity, + fetch: mockFetch, + }); + await provider(); + + const body = JSON.parse((mockFetch as jest.Mock).mock.calls[0]![1].body); + expect(body.assertion).toBe('async-jwt'); + }); +}); diff --git a/tests/lib/credentials/token-cache.test.ts b/tests/lib/credentials/token-cache.test.ts new file mode 100644 index 00000000..3a1eff0e --- /dev/null +++ b/tests/lib/credentials/token-cache.test.ts @@ -0,0 +1,260 @@ +import { TokenCache } from '@anthropic-ai/sdk/lib/credentials/token-cache'; +import type { AccessToken, AccessTokenProvider } from '@anthropic-ai/sdk/lib/credentials/types'; + +let fakeNow = 1700000000; + +beforeAll(() => { + jest.useFakeTimers({ now: fakeNow * 1000 }); +}); + +afterAll(() => { + jest.useRealTimers(); +}); + +function setFakeNow(seconds: number) { + fakeNow = seconds; + jest.setSystemTime(seconds * 1000); +} + +describe('TokenCache', () => { + it('fetches on first call when cache is empty', async () => { + const provider = jest.fn, []>().mockResolvedValue({ + token: 'tok-1', + expiresAt: fakeNow + 3600, + }); + + const cache = new TokenCache(provider); + expect(await cache.getToken()).toBe('tok-1'); + expect(provider).toHaveBeenCalledTimes(1); + }); + + it('returns cached token without calling provider when fresh', async () => { + const provider = jest.fn, []>().mockResolvedValue({ + token: 'tok-1', + expiresAt: fakeNow + 3600, + }); + + const cache = new TokenCache(provider); + await cache.getToken(); + expect(provider).toHaveBeenCalledTimes(1); + + // Second call — still fresh + expect(await cache.getToken()).toBe('tok-1'); + expect(provider).toHaveBeenCalledTimes(1); + }); + + it('caches forever when expiresAt is null', async () => { + const provider = jest.fn, []>().mockResolvedValue({ + token: 'eternal', + expiresAt: null, + }); + + const cache = new TokenCache(provider); + expect(await cache.getToken()).toBe('eternal'); + expect(await cache.getToken()).toBe('eternal'); + expect(provider).toHaveBeenCalledTimes(1); + }); + + it('returns stale token in advisory window and refreshes in background', async () => { + let callCount = 0; + const initialExpiry = fakeNow + 3600; + const provider: AccessTokenProvider = () => { + callCount++; + return Promise.resolve({ + token: `tok-${callCount}`, + expiresAt: fakeNow + 3600, + }); + }; + + const cache = new TokenCache(provider); + await cache.getToken(); // initial fetch + expect(callCount).toBe(1); + + // Advance to advisory window (90s remaining, within 30–120s) + setFakeNow(initialExpiry - 90); + + // Should return stale token immediately + const token = await cache.getToken(); + expect(token).toBe('tok-1'); + + // Flush the microtask queue so the background refresh completes + await Promise.resolve(); + + // Next call should get the refreshed token + expect(await cache.getToken()).toBe('tok-2'); + }); + + it('keeps stale token when advisory background refresh fails', async () => { + let callCount = 0; + const provider: AccessTokenProvider = () => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ token: 'tok-1', expiresAt: fakeNow + 3600 }); + } + return Promise.reject(new Error('refresh failed')); + }; + + const onError = jest.fn(); + const cache = new TokenCache(provider, onError); + await cache.getToken(); + + // Advance to advisory window + setFakeNow(fakeNow + 3600 - 90); + + expect(await cache.getToken()).toBe('tok-1'); + + // Flush the microtask queue so the failed background refresh settles + await Promise.resolve(); + + // Still returns stale token + expect(await cache.getToken()).toBe('tok-1'); + // Advisory failure surfaced to the callback (for debug logging) + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'refresh failed' })); + }); + + it('blocks and refreshes in mandatory window', async () => { + let callCount = 0; + const initialExpiry = fakeNow + 3600; + const provider: AccessTokenProvider = () => { + callCount++; + return Promise.resolve({ + token: `tok-${callCount}`, + expiresAt: fakeNow + 3600, + }); + }; + + const cache = new TokenCache(provider); + await cache.getToken(); + expect(callCount).toBe(1); + + // Advance to mandatory window (10s remaining) + setFakeNow(initialExpiry - 10); + + const token = await cache.getToken(); + expect(token).toBe('tok-2'); + expect(callCount).toBe(2); + }); + + it('throws when mandatory refresh fails', async () => { + let callCount = 0; + const provider: AccessTokenProvider = () => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ token: 'tok-1', expiresAt: fakeNow + 3600 }); + } + return Promise.reject(new Error('exchange down')); + }; + + const cache = new TokenCache(provider); + await cache.getToken(); + + // Advance past expiry + setFakeNow(fakeNow + 3700); + + await expect(cache.getToken()).rejects.toThrow('exchange down'); + }); + + it('deduplicates concurrent mandatory refreshes', async () => { + let callCount = 0; + let resolveProvider!: (token: AccessToken) => void; + + const provider: AccessTokenProvider = () => { + callCount++; + return new Promise((resolve) => { + resolveProvider = resolve; + }); + }; + + const cache = new TokenCache(provider); + + // Fire multiple concurrent getToken calls + const p1 = cache.getToken(); + const p2 = cache.getToken(); + const p3 = cache.getToken(); + + // Only one provider call should be in-flight + expect(callCount).toBe(1); + + resolveProvider({ token: 'shared-tok', expiresAt: fakeNow + 3600 }); + + const [t1, t2, t3] = await Promise.all([p1, p2, p3]); + expect(t1).toBe('shared-tok'); + expect(t2).toBe('shared-tok'); + expect(t3).toBe('shared-tok'); + expect(callCount).toBe(1); + }); + + it('invalidate forces re-fetch on next call', async () => { + let callCount = 0; + const provider: AccessTokenProvider = () => { + callCount++; + return Promise.resolve({ token: `tok-${callCount}`, expiresAt: fakeNow + 3600 }); + }; + + const cache = new TokenCache(provider); + expect(await cache.getToken()).toBe('tok-1'); + + cache.invalidate(); + + expect(await cache.getToken()).toBe('tok-2'); + expect(callCount).toBe(2); + }); + + it('invalidate passes forceRefresh to provider so disk caches are bypassed', async () => { + const calls: Array<{ forceRefresh?: boolean } | undefined> = []; + const provider: AccessTokenProvider = (opts) => { + calls.push(opts); + return Promise.resolve({ token: 'tok', expiresAt: fakeNow + 3600 }); + }; + + const cache = new TokenCache(provider); + await cache.getToken(); + expect(calls[0]).toBeUndefined(); + + cache.invalidate(); + await cache.getToken(); + expect(calls[1]).toEqual({ forceRefresh: true }); + + // One-shot: subsequent normal calls do not force. + cache.invalidate(); + await cache.getToken(); + await cache.getToken(); // cached, no provider call + expect(calls.length).toBe(3); + }); + + it('backs off advisory refresh for 5s after a failure', async () => { + setFakeNow(1700000000); + let callCount = 0; + const provider: AccessTokenProvider = () => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ token: 'tok-1', expiresAt: fakeNow + 3600 }); + } + return Promise.reject(new Error('refresh failed')); + }; + + const onError = jest.fn(); + const cache = new TokenCache(provider, onError); + await cache.getToken(); + + // Advance to advisory window + setFakeNow(fakeNow + 3600 - 90); + await cache.getToken(); // triggers background refresh → fails (call #2) + await Promise.resolve(); + expect(callCount).toBe(2); + expect(onError).toHaveBeenCalledTimes(1); + + // Within backoff window: another getToken should NOT trigger a provider call. + setFakeNow(fakeNow + 2); + await cache.getToken(); + await Promise.resolve(); + expect(callCount).toBe(2); + + // After backoff window: provider is tried again. + setFakeNow(fakeNow + 4); + await cache.getToken(); + await Promise.resolve(); + expect(callCount).toBe(3); + expect(onError).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/lib/credentials/user-oauth.test.ts b/tests/lib/credentials/user-oauth.test.ts new file mode 100644 index 00000000..cd7474b9 --- /dev/null +++ b/tests/lib/credentials/user-oauth.test.ts @@ -0,0 +1,284 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import type { Fetch } from '@anthropic-ai/sdk/internal/builtin-types'; +import { userOAuthProvider } from '@anthropic-ai/sdk/lib/credentials/user-oauth'; +import { + WorkloadIdentityError, + OAUTH_API_BETA_HEADER, + FEDERATION_BETA_HEADER, + writeCredentialsFileAtomic, +} from '@anthropic-ai/sdk/lib/credentials/types'; + +const NOW_IN_SECONDS = 1700000000; + +beforeAll(() => { + jest.useFakeTimers({ now: NOW_IN_SECONDS * 1000 }); +}); + +afterAll(() => { + jest.useRealTimers(); +}); + +function jsonResponse(body: object, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('userOAuthProvider', () => { + let testDir: string; + let credPath: string; + + beforeEach(() => { + testDir = mkdtempSync(path.join(tmpdir(), 'user-oauth-test-')); + credPath = path.join(testDir, 'credentials.json'); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true }); + }); + + const baseConfig = { + baseURL: 'https://api.anthropic.com', + clientId: 'my-client', + }; + + const writeCreds = (data: string | object, mode = 0o600) => + fs.writeFileSync(credPath, typeof data === 'string' ? data : JSON.stringify(data), { mode }); + + it('returns cached token when still fresh', async () => { + const futureExpiry = NOW_IN_SECONDS + 3600; + writeCreds({ + access_token: 'cached-token', + refresh_token: 'refresh-tok', + expires_at: futureExpiry, + }); + + const mockFetch: Fetch = jest.fn(); + const provider = userOAuthProvider({ ...baseConfig, credentialsPath: credPath, fetch: mockFetch }); + const token = await provider(); + + expect(token.token).toBe('cached-token'); + expect(token.expiresAt).toBe(futureExpiry); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('forceRefresh skips the disk freshness short-circuit and refreshes anyway', async () => { + writeCreds({ + access_token: 'stale-but-unexpired', + refresh_token: 'refresh-tok', + expires_at: NOW_IN_SECONDS + 3600, + }); + + const mockFetch = jest + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ access_token: 'forced-new', expires_in: 3600 }), { status: 200 }), + ); + const provider = userOAuthProvider({ + ...baseConfig, + credentialsPath: credPath, + fetch: mockFetch as unknown as Fetch, + }); + + const token = await provider({ forceRefresh: true }); + expect(token.token).toBe('forced-new'); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('refreshes when token is expired and sends only the OAuth beta header', async () => { + const pastExpiry = NOW_IN_SECONDS - 10; + writeCreds({ + access_token: 'stale-token', + refresh_token: 'refresh-tok', + expires_at: pastExpiry, + }); + + const mockFetch: Fetch = jest + .fn() + .mockResolvedValue( + jsonResponse({ access_token: 'new-token', expires_in: 3600, refresh_token: 'new-refresh' }), + ); + + const provider = userOAuthProvider({ ...baseConfig, credentialsPath: credPath, fetch: mockFetch }); + const token = await provider(); + + expect(token.token).toBe('new-token'); + expect(token.expiresAt).toBe(NOW_IN_SECONDS + 3600); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const [url, init] = (mockFetch as jest.Mock).mock.calls[0]!; + expect(url).toBe('https://api.anthropic.com/v1/oauth/token'); + const body = JSON.parse(init.body); + expect(body.grant_type).toBe('refresh_token'); + expect(body.refresh_token).toBe('refresh-tok'); + expect(body.client_id).toBe('my-client'); + expect(init.headers['anthropic-beta']).toBe(OAUTH_API_BETA_HEADER); + expect(init.headers['anthropic-beta']).not.toContain(FEDERATION_BETA_HEADER); + expect(init.headers['User-Agent']).toMatch(/^anthropic-sdk-typescript\//); + }); + + it('writes refreshed credentials back to file with no .tmp leftover', async () => { + writeCreds({ + access_token: 'stale', + refresh_token: 'old-refresh', + expires_at: NOW_IN_SECONDS - 10, + }); + + const mockFetch: Fetch = jest + .fn() + .mockResolvedValue( + jsonResponse({ access_token: 'new-tok', expires_in: 7200, refresh_token: 'rotated-refresh' }), + ); + + const provider = userOAuthProvider({ ...baseConfig, credentialsPath: credPath, fetch: mockFetch }); + await provider(); + + const written = JSON.parse(fs.readFileSync(credPath, 'utf-8')); + expect(written.version).toBe('1.0'); + expect(written.access_token).toBe('new-tok'); + expect(written.expires_at).toBe(NOW_IN_SECONDS + 7200); + expect(written.refresh_token).toBe('rotated-refresh'); + expect(written.type).toBe('oauth_token'); + expect(fs.readdirSync(testDir).filter((f) => f.endsWith('.tmp'))).toEqual([]); + }); + + it('treats token as static when clientId is empty', async () => { + writeCreds({ access_token: 'static-tok' }); + + const mockFetch: Fetch = jest.fn(); + const provider = userOAuthProvider({ + ...baseConfig, + clientId: undefined, + credentialsPath: credPath, + fetch: mockFetch, + }); + const token = await provider(); + + expect(token.token).toBe('static-tok'); + expect(token.expiresAt).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('throws when expired and no refresh available (empty clientId)', async () => { + writeCreds({ access_token: 'expired', expires_at: NOW_IN_SECONDS - 10, refresh_token: 'r' }); + + const mockFetch: Fetch = jest.fn(); + const provider = userOAuthProvider({ + ...baseConfig, + clientId: undefined, + credentialsPath: credPath, + fetch: mockFetch, + }); + + await expect(provider()).rejects.toThrow('no refresh is available'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('throws when credentials file is missing access_token', async () => { + writeCreds({ refresh_token: 'r' }); + + const mockFetch: Fetch = jest.fn(); + const provider = userOAuthProvider({ ...baseConfig, credentialsPath: credPath, fetch: mockFetch }); + + await expect(provider()).rejects.toThrow("must include 'access_token'"); + }); + + it('throws WorkloadIdentityError on corrupt credentials JSON', async () => { + writeCreds('{not json'); + + const provider = userOAuthProvider({ ...baseConfig, credentialsPath: credPath, fetch: jest.fn() }); + await expect(provider()).rejects.toThrow(WorkloadIdentityError); + await expect(provider()).rejects.toThrow('not valid JSON'); + }); + + it('throws when refresh response lacks expires_in (fail closed)', async () => { + writeCreds({ access_token: 'x', refresh_token: 'y', expires_at: 0 }); + + const mockFetch: Fetch = jest.fn().mockResolvedValue(jsonResponse({ access_token: 'tok' })); + + const provider = userOAuthProvider({ ...baseConfig, credentialsPath: credPath, fetch: mockFetch }); + await expect(provider()).rejects.toThrow('missing or invalid expires_in'); + }); + + it('throws on refresh failure', async () => { + writeCreds({ access_token: 'x', refresh_token: 'y', expires_at: 0 }); + + const mockFetch: Fetch = jest.fn().mockResolvedValue(new Response('bad', { status: 400 })); + + const provider = userOAuthProvider({ ...baseConfig, credentialsPath: credPath, fetch: mockFetch }); + await expect(provider()).rejects.toThrow(WorkloadIdentityError); + }); + + it('rejects non-https token endpoint on refresh', async () => { + writeCreds({ access_token: 'x', refresh_token: 'y', expires_at: 0 }); + + const provider = userOAuthProvider({ + ...baseConfig, + baseURL: 'http://api.example.com', + credentialsPath: credPath, + fetch: jest.fn(), + }); + await expect(provider()).rejects.toThrow('non-https token endpoint'); + }); + + it('rejects non-Bearer token_type on refresh', async () => { + writeCreds({ access_token: 'x', refresh_token: 'y', expires_at: 0 }); + + const mockFetch: Fetch = jest + .fn() + .mockResolvedValue(jsonResponse({ access_token: 'tok', token_type: 'mac', expires_in: 60 })); + + const provider = userOAuthProvider({ ...baseConfig, credentialsPath: credPath, fetch: mockFetch }); + await expect(provider()).rejects.toThrow('unsupported token_type'); + }); + + it('writeCredentialsFileAtomic cleans up tmp file on write failure', async () => { + // Target a path inside a non-existent dir whose parent is a regular file, + // so mkdir succeeds for the immediate dir? No — simpler: target a path + // that IS a directory so the rename onto it fails (or open fails). + fs.mkdirSync(credPath); // credPath is now a directory → rename onto it fails + await expect(writeCredentialsFileAtomic(credPath, { x: 1 })).rejects.toThrow(); + // Nothing matching .tmp should be left behind in testDir + expect(fs.readdirSync(testDir).filter((f) => f.endsWith('.tmp'))).toEqual([]); + }); + + const itPosix = process.platform === 'win32' ? it.skip : it; + + itPosix('follows a symlinked credentials file and verifies the target', async () => { + const real = path.join(testDir, 'real.json'); + fs.writeFileSync(real, JSON.stringify({ access_token: 'via-symlink' }), { mode: 0o600 }); + fs.symlinkSync(real, credPath); + + const provider = userOAuthProvider({ ...baseConfig, credentialsPath: credPath, fetch: jest.fn() }); + const token = await provider(); + expect(token.token).toBe('via-symlink'); + + // If the symlink target has a bad mode, the resolved path is what's reported + fs.chmodSync(real, 0o644); + await expect(provider()).rejects.toThrow(/group\/world-readable.*real\.json/); + }); + + itPosix('refuses a group/world-readable credentials file', async () => { + writeCreds({ access_token: 'x' }); + fs.chmodSync(credPath, 0o644); + const provider = userOAuthProvider({ ...baseConfig, credentialsPath: credPath, fetch: jest.fn() }); + await expect(provider()).rejects.toThrow('group/world-readable'); + + fs.chmodSync(credPath, 0o640); + await expect(provider()).rejects.toThrow('group/world-readable'); + }); + + itPosix('refuses a group/world-writable credentials file', async () => { + writeCreds({ access_token: 'x' }); + fs.chmodSync(credPath, 0o602); + const provider = userOAuthProvider({ ...baseConfig, credentialsPath: credPath, fetch: jest.fn() }); + await expect(provider()).rejects.toThrow('group/world-writable'); + + fs.chmodSync(credPath, 0o620); + await expect(provider()).rejects.toThrow('group/world-writable'); + }); +});