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; /** * 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; /** * 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; } if (config.workspaceId) { body['workspace_id'] = config.workspaceId; } 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); // 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}${hint}`, 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, }; }; }