Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
".": "0.93.0",
".": "0.94.0",
"packages/vertex-sdk": "0.16.0",
"packages/bedrock-sdk": "0.29.1",
"packages/foundry-sdk": "0.2.3",
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 0.94.0 (2026-05-05)

Full Changelog: [sdk-v0.93.0...sdk-v0.94.0](https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.93.0...sdk-v0.94.0)

### Features

* **client:** allow targeting a workspace for OIDC federation token exchange ([bde6620](https://github.com/anthropics/anthropic-sdk-typescript/commit/bde6620d68c2dd9e8e641b4cfed8847486ff046f))

## 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)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@anthropic-ai/sdk",
"version": "0.93.0",
"version": "0.94.0",
"description": "The official TypeScript library for the Anthropic API",
"author": "Anthropic <support@anthropic.com>",
"types": "dist/index.d.ts",
Expand Down
2 changes: 1 addition & 1 deletion packages/vertex-sdk/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

"@anthropic-ai/sdk@file:../../dist":
# x-release-please-start-version
version "0.93.0"
version "0.94.0"
# x-release-please-end-version
dependencies:
json-schema-to-ts "^3.1.1"
Expand Down
48 changes: 39 additions & 9 deletions src/core/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ function validateProfileName(name: string): void {
* in the file take precedence — env vars only fill gaps:
* - `ANTHROPIC_BASE_URL` → `base_url`
* - `ANTHROPIC_ORGANIZATION_ID` → `organization_id`
* - `ANTHROPIC_WORKSPACE_ID` → `workspace_id`
* - `ANTHROPIC_SCOPE` → `authentication.scope`
* - `ANTHROPIC_FEDERATION_RULE_ID` → `authentication.federation_rule_id` (oidc_federation)
* - `ANTHROPIC_IDENTITY_TOKEN_FILE` → `authentication.identity_token` (oidc_federation)
Expand All @@ -114,6 +115,26 @@ function validateProfileName(name: string): void {
* and `ANTHROPIC_ORGANIZATION_ID` are set.
*/
export const loadConfig = async (profile?: string): Promise<AnthropicConfig | null> => {
return (await loadConfigWithSource(profile))?.config ?? null;
};

/**
* Source-tagged result of {@link loadConfigWithSource}. `fromFile` is `true`
* when `<config_dir>/configs/<profile>.json` exists on disk; `false` when the
* config was synthesized purely from environment variables.
*
* The credential chain uses this distinction to decide whether to back the
* federation exchange with a disk cache: file-backed profiles get a cache at
* `<config_dir>/credentials/<profile>.json`, env-only configs do not.
*/
export type LoadedConfig = { config: AnthropicConfig; fromFile: boolean };

/**
* Same as {@link loadConfig}, but also reports whether the config was loaded
* from a profile file on disk (`fromFile: true`) or synthesized entirely from
* environment variables (`fromFile: false`).
*/
export const loadConfigWithSource = async (profile?: string): Promise<LoadedConfig | null> => {
const rootConfigPath = await getRootConfigPath();
if (rootConfigPath === null) {
return null;
Expand Down Expand Up @@ -143,14 +164,22 @@ export const loadConfig = async (profile?: string): Promise<AnthropicConfig | nu
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'),
fromFile: false,
config: {
organization_id: organizationId,
// A defaulted-but-empty CI variable (`ANTHROPIC_WORKSPACE_ID=""`) is
// treated as unset — readEnv coerces empty to undefined, and the body
// builder's truthy check skips it — so `"workspace_id": ""` never goes
// on the wire.
workspace_id: readEnv('ANTHROPIC_WORKSPACE_ID'),
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'),
},
},
};
}
Expand All @@ -173,6 +202,7 @@ export const loadConfig = async (profile?: string): Promise<AnthropicConfig | nu

// File values are authoritative; env vars only fill fields the file left unset.
config.organization_id ??= readEnv('ANTHROPIC_ORGANIZATION_ID');
config.workspace_id ??= readEnv('ANTHROPIC_WORKSPACE_ID');
config.base_url ??= readEnv('ANTHROPIC_BASE_URL');
config.authentication.scope ??= readEnv('ANTHROPIC_SCOPE');

Expand All @@ -197,7 +227,7 @@ export const loadConfig = async (profile?: string): Promise<AnthropicConfig | nu
config.authentication.service_account_id ??= readEnv('ANTHROPIC_SERVICE_ACCOUNT_ID');
}

return config;
return { config, fromFile: true };
};

/**
Expand Down
30 changes: 19 additions & 11 deletions src/lib/credentials/credential-chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Fetch } from '../../internal/builtin-types';
import { readEnv } from '../../internal/utils/env';
import {
CREDENTIALS_FILE_VERSION,
loadConfig,
loadConfigWithSource,
getCredentialsPath,
type AnthropicConfig,
} from '../../core/credentials';
Expand Down Expand Up @@ -47,9 +47,9 @@ export function resolveCredentialsFromConfig(
const provider = buildProvider(config, credentialsPath, effectiveBaseURL, options);

const extraHeaders: Record<string, string> = {};
// 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.
// For federation profiles workspace_id is sent in the jwt-bearer exchange
// body, not as a request header (the minted token is already
// workspace-scoped, so the header would be ignored).
if (config.workspace_id && config.authentication.type === 'user_oauth') {
extraHeaders['anthropic-workspace-id'] = config.workspace_id;
}
Expand Down Expand Up @@ -79,17 +79,24 @@ export async function defaultCredentials(
options: ResolverOptions,
profile?: string,
): Promise<CredentialResult | null> {
const config = await loadConfig(profile);
if (!config) {
const loaded = await loadConfigWithSource(profile);
if (!loaded) {
return null;
}
const { config, fromFile } = loaded;

// 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.
// For 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.
//
// Env-only credentials (no profile file on disk) skip the disk cache —
// matching the other SDKs. A disk cache keyed by profile path would
// re-serve a stale token after a change to ANTHROPIC_WORKSPACE_ID (or
// ANTHROPIC_ORGANIZATION_ID / ANTHROPIC_FEDERATION_RULE_ID) until the
// cached token expired, so the env-only chain stays in-memory only.
const withPath: AnthropicConfig =
config.authentication.credentials_path ?
config.authentication.credentials_path || !fromFile ?
config
: {
...config,
Expand Down Expand Up @@ -134,6 +141,7 @@ function buildProvider(
federationRuleId: auth.federation_rule_id,
organizationId: config.organization_id,
serviceAccountId: auth.service_account_id,
workspaceId: config.workspace_id,
baseURL,
fetch: options.fetch,
userAgent: options.userAgent,
Expand Down
30 changes: 29 additions & 1 deletion src/lib/credentials/oidc-federation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@ export type OIDCFederationConfig = {
federationRuleId: string;
organizationId: string;
serviceAccountId?: string | undefined;
/**
* Optional `wrkspc_*` tagged ID, or the literal `"default"` to scope the
* token to the organization's default workspace. When omitted the server
* picks the rule's sole enabled workspace, else the org default if the rule
* covers it. Required when the rule enables more than one non-default
* workspace, or to target a specific workspace other than the one the
* server would pick. The minted token is workspace-scoped: per-request
* workspace selection (the `anthropic-workspace-id` header) is not supported
* for federation tokens — switching workspaces requires a new token exchange
* with a different `workspaceId`.
*/
workspaceId?: string | undefined;
baseURL: string;
fetch: Fetch;
/**
Expand Down Expand Up @@ -61,6 +73,9 @@ export function oidcFederationProvider(config: OIDCFederationConfig): AccessToke
if (config.serviceAccountId) {
body['service_account_id'] = config.serviceAccountId;
}
if (config.workspaceId) {
body['workspace_id'] = config.workspaceId;
}

const url = `${config.baseURL}${TOKEN_ENDPOINT}`;
let resp: Response;
Expand All @@ -83,10 +98,23 @@ export function oidcFederationProvider(config: OIDCFederationConfig): AccessToke
if (!resp.ok) {
const text = await resp.text().catch(() => '');
const redacted = redactSensitive(text);
// A 401 is hard to debug from the status code alone, so surface
// guidance: check the federation rule, optionally set a workspace ID
// (the most common fix when no workspaceId is configured), and point at
// the Workload identity page in Claude Console for the server-side
// authentication event log. Other statuses (5xx, 400, ...) get no hint.
let hint = '';
if (resp.status === 401) {
const hintMiddle =
config.workspaceId ? '' : (
"If your federation rule is scoped to multiple workspaces, set the ANTHROPIC_WORKSPACE_ID environment variable, the 'workspace_id' config key, or the `workspaceId` option. "
);
hint = ` Ensure your federation rule matches your identity token. ${hintMiddle}View your authentication events in the Workload identity page of Claude Console for more details.`;
}
throw new WorkloadIdentityError(
`Token exchange failed with status ${resp.status}${
requestId ? ` (request-id ${requestId})` : ''
}: ${redacted}`,
}: ${redacted}${hint}`,
resp.status,
redacted,
requestId,
Expand Down
2 changes: 1 addition & 1 deletion src/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const VERSION = '0.93.0'; // x-release-please-version
export const VERSION = '0.94.0'; // x-release-please-version
4 changes: 4 additions & 0 deletions tests/lib/credentials/client-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ describe('client credentials integration', () => {
apiKey: null,
config: {
organization_id: 'org-explicit',
workspace_id: 'wrkspc_x',
authentication: {
type: 'oidc_federation',
federation_rule_id: 'fdrl_explicit',
Expand All @@ -349,9 +350,12 @@ describe('client credentials integration', () => {
const body = JSON.parse(init!.body as string);
expect(body.federation_rule_id).toBe('fdrl_explicit');
expect(body.assertion).toBe('explicit-jwt');
expect(body.workspace_id).toBe('wrkspc_x');
return jsonResponse({ access_token: 'explicit-tok', expires_in: 3600 });
}
expect(getHeader(init, 'authorization')).toBe('Bearer explicit-tok');
// Federation profiles send workspace_id in the exchange body, not as a header.
expect(getHeader(init, 'anthropic-workspace-id')).toBeNull();
return jsonResponse(VALID_MSG_RESPONSE);
},
});
Expand Down
Loading
Loading