@anthropic-ai/sdk 0.91.1 → 0.93.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/client.d.mts +73 -0
  3. package/client.d.mts.map +1 -1
  4. package/client.d.ts +73 -0
  5. package/client.d.ts.map +1 -1
  6. package/client.js +269 -9
  7. package/client.js.map +1 -1
  8. package/client.mjs +269 -9
  9. package/client.mjs.map +1 -1
  10. package/core/credentials.d.mts +111 -0
  11. package/core/credentials.d.mts.map +1 -0
  12. package/core/credentials.d.ts +111 -0
  13. package/core/credentials.d.ts.map +1 -0
  14. package/core/credentials.js +298 -0
  15. package/core/credentials.js.map +1 -0
  16. package/core/credentials.mjs +259 -0
  17. package/core/credentials.mjs.map +1 -0
  18. package/internal/types.d.mts +12 -6
  19. package/internal/types.d.ts +12 -6
  20. package/internal/utils/time.d.mts +3 -0
  21. package/internal/utils/time.d.mts.map +1 -0
  22. package/internal/utils/time.d.ts +3 -0
  23. package/internal/utils/time.d.ts.map +1 -0
  24. package/internal/utils/time.js +8 -0
  25. package/internal/utils/time.js.map +1 -0
  26. package/internal/utils/time.mjs +5 -0
  27. package/internal/utils/time.mjs.map +1 -0
  28. package/lib/credentials/credential-chain.d.mts +39 -0
  29. package/lib/credentials/credential-chain.d.mts.map +1 -0
  30. package/lib/credentials/credential-chain.d.ts +39 -0
  31. package/lib/credentials/credential-chain.d.ts.map +1 -0
  32. package/lib/credentials/credential-chain.js +237 -0
  33. package/lib/credentials/credential-chain.js.map +1 -0
  34. package/lib/credentials/credential-chain.mjs +200 -0
  35. package/lib/credentials/credential-chain.mjs.map +1 -0
  36. package/lib/credentials/identity-token.d.mts +11 -0
  37. package/lib/credentials/identity-token.d.mts.map +1 -0
  38. package/lib/credentials/identity-token.d.ts +11 -0
  39. package/lib/credentials/identity-token.d.ts.map +1 -0
  40. package/lib/credentials/identity-token.js +72 -0
  41. package/lib/credentials/identity-token.js.map +1 -0
  42. package/lib/credentials/identity-token.mjs +35 -0
  43. package/lib/credentials/identity-token.mjs.map +1 -0
  44. package/lib/credentials/oidc-federation.d.mts +28 -0
  45. package/lib/credentials/oidc-federation.d.mts.map +1 -0
  46. package/lib/credentials/oidc-federation.d.ts +28 -0
  47. package/lib/credentials/oidc-federation.d.ts.map +1 -0
  48. package/lib/credentials/oidc-federation.js +69 -0
  49. package/lib/credentials/oidc-federation.js.map +1 -0
  50. package/lib/credentials/oidc-federation.mjs +66 -0
  51. package/lib/credentials/oidc-federation.mjs.map +1 -0
  52. package/lib/credentials/token-cache.d.mts +54 -0
  53. package/lib/credentials/token-cache.d.mts.map +1 -0
  54. package/lib/credentials/token-cache.d.ts +54 -0
  55. package/lib/credentials/token-cache.d.ts.map +1 -0
  56. package/lib/credentials/token-cache.js +112 -0
  57. package/lib/credentials/token-cache.js.map +1 -0
  58. package/lib/credentials/token-cache.mjs +108 -0
  59. package/lib/credentials/token-cache.mjs.map +1 -0
  60. package/lib/credentials/types.d.mts +96 -0
  61. package/lib/credentials/types.d.mts.map +1 -0
  62. package/lib/credentials/types.d.ts +96 -0
  63. package/lib/credentials/types.d.ts.map +1 -0
  64. package/lib/credentials/types.js +266 -0
  65. package/lib/credentials/types.js.map +1 -0
  66. package/lib/credentials/types.mjs +224 -0
  67. package/lib/credentials/types.mjs.map +1 -0
  68. package/lib/credentials/user-oauth.d.mts +21 -0
  69. package/lib/credentials/user-oauth.d.mts.map +1 -0
  70. package/lib/credentials/user-oauth.d.ts +21 -0
  71. package/lib/credentials/user-oauth.d.ts.map +1 -0
  72. package/lib/credentials/user-oauth.js +130 -0
  73. package/lib/credentials/user-oauth.js.map +1 -0
  74. package/lib/credentials/user-oauth.mjs +94 -0
  75. package/lib/credentials/user-oauth.mjs.map +1 -0
  76. package/lib/credentials.d.mts +4 -0
  77. package/lib/credentials.d.mts.map +1 -0
  78. package/lib/credentials.d.ts +4 -0
  79. package/lib/credentials.d.ts.map +1 -0
  80. package/lib/credentials.js +8 -0
  81. package/lib/credentials.js.map +1 -0
  82. package/lib/credentials.mjs +3 -0
  83. package/lib/credentials.mjs.map +1 -0
  84. package/package.json +1 -1
  85. package/resources/beta/environments.d.mts +1 -1
  86. package/resources/beta/environments.d.ts +1 -1
  87. package/resources/beta/index.d.mts +1 -1
  88. package/resources/beta/index.d.mts.map +1 -1
  89. package/resources/beta/index.d.ts +1 -1
  90. package/resources/beta/index.d.ts.map +1 -1
  91. package/resources/beta/index.js.map +1 -1
  92. package/resources/beta/index.mjs.map +1 -1
  93. package/resources/beta/memory-stores/memories.d.mts +126 -11
  94. package/resources/beta/memory-stores/memories.d.mts.map +1 -1
  95. package/resources/beta/memory-stores/memories.d.ts +126 -11
  96. package/resources/beta/memory-stores/memories.d.ts.map +1 -1
  97. package/resources/beta/memory-stores/memories.js +5 -5
  98. package/resources/beta/memory-stores/memories.mjs +5 -5
  99. package/resources/beta/memory-stores/memory-stores.d.mts +58 -14
  100. package/resources/beta/memory-stores/memory-stores.d.mts.map +1 -1
  101. package/resources/beta/memory-stores/memory-stores.d.ts +58 -14
  102. package/resources/beta/memory-stores/memory-stores.d.ts.map +1 -1
  103. package/resources/beta/memory-stores/memory-stores.js +6 -6
  104. package/resources/beta/memory-stores/memory-stores.js.map +1 -1
  105. package/resources/beta/memory-stores/memory-stores.mjs +6 -6
  106. package/resources/beta/memory-stores/memory-stores.mjs.map +1 -1
  107. package/resources/beta/memory-stores/memory-versions.d.mts +88 -5
  108. package/resources/beta/memory-stores/memory-versions.d.mts.map +1 -1
  109. package/resources/beta/memory-stores/memory-versions.d.ts +88 -5
  110. package/resources/beta/memory-stores/memory-versions.d.ts.map +1 -1
  111. package/resources/beta/memory-stores/memory-versions.js +3 -3
  112. package/resources/beta/memory-stores/memory-versions.mjs +3 -3
  113. package/resources/beta/messages/batches.d.mts +4 -0
  114. package/resources/beta/messages/batches.d.mts.map +1 -1
  115. package/resources/beta/messages/batches.d.ts +4 -0
  116. package/resources/beta/messages/batches.d.ts.map +1 -1
  117. package/resources/beta/messages/index.d.mts +1 -1
  118. package/resources/beta/messages/index.d.mts.map +1 -1
  119. package/resources/beta/messages/index.d.ts +1 -1
  120. package/resources/beta/messages/index.d.ts.map +1 -1
  121. package/resources/beta/messages/index.js.map +1 -1
  122. package/resources/beta/messages/index.mjs.map +1 -1
  123. package/resources/beta/messages/messages.d.mts +5 -1
  124. package/resources/beta/messages/messages.d.mts.map +1 -1
  125. package/resources/beta/messages/messages.d.ts +5 -1
  126. package/resources/beta/messages/messages.d.ts.map +1 -1
  127. package/resources/beta/messages/messages.js +1 -1
  128. package/resources/beta/messages/messages.js.map +1 -1
  129. package/resources/beta/messages/messages.mjs +1 -1
  130. package/resources/beta/messages/messages.mjs.map +1 -1
  131. package/resources/beta/sessions/sessions.d.mts +5 -0
  132. package/resources/beta/sessions/sessions.d.mts.map +1 -1
  133. package/resources/beta/sessions/sessions.d.ts +5 -0
  134. package/resources/beta/sessions/sessions.d.ts.map +1 -1
  135. package/resources/beta/sessions/sessions.js.map +1 -1
  136. package/resources/beta/sessions/sessions.mjs.map +1 -1
  137. package/resources/messages/messages.d.mts +4 -0
  138. package/resources/messages/messages.d.mts.map +1 -1
  139. package/resources/messages/messages.d.ts +4 -0
  140. package/resources/messages/messages.d.ts.map +1 -1
  141. package/resources/messages/messages.js.map +1 -1
  142. package/resources/messages/messages.mjs.map +1 -1
  143. package/src/client.ts +354 -14
  144. package/src/core/credentials.ts +349 -0
  145. package/src/internal/utils/time.ts +4 -0
  146. package/src/lib/credentials/credential-chain.ts +284 -0
  147. package/src/lib/credentials/identity-token.ts +37 -0
  148. package/src/lib/credentials/oidc-federation.ts +112 -0
  149. package/src/lib/credentials/token-cache.ts +130 -0
  150. package/src/lib/credentials/types.ts +295 -0
  151. package/src/lib/credentials/user-oauth.ts +144 -0
  152. package/src/lib/credentials.ts +3 -0
  153. package/src/resources/beta/environments.ts +1 -1
  154. package/src/resources/beta/index.ts +1 -0
  155. package/src/resources/beta/memory-stores/memories.ts +126 -11
  156. package/src/resources/beta/memory-stores/memory-stores.ts +58 -14
  157. package/src/resources/beta/memory-stores/memory-versions.ts +88 -5
  158. package/src/resources/beta/messages/batches.ts +4 -0
  159. package/src/resources/beta/messages/index.ts +1 -0
  160. package/src/resources/beta/messages/messages.ts +5 -1
  161. package/src/resources/beta/sessions/sessions.ts +6 -0
  162. package/src/resources/messages/messages.ts +4 -0
  163. package/src/version.ts +1 -1
  164. package/version.d.mts +1 -1
  165. package/version.d.ts +1 -1
  166. package/version.js +1 -1
  167. package/version.mjs +1 -1
@@ -0,0 +1,112 @@
1
+ import type { Fetch } from '../../internal/builtin-types';
2
+ import type { AccessTokenProvider, IdentityTokenProvider } from './types';
3
+ import {
4
+ FEDERATION_BETA_HEADER,
5
+ GRANT_TYPE_JWT_BEARER,
6
+ OAUTH_API_BETA_HEADER,
7
+ TOKEN_ENDPOINT,
8
+ WorkloadIdentityError,
9
+ parseTokenResponse,
10
+ redactSensitive,
11
+ requireSecureTokenEndpoint,
12
+ } from './types';
13
+ import { nowAsSeconds } from '../../internal/utils/time';
14
+ import { VERSION } from '../../version';
15
+
16
+ export type OIDCFederationConfig = {
17
+ identityTokenProvider: IdentityTokenProvider;
18
+ federationRuleId: string;
19
+ organizationId: string;
20
+ serviceAccountId?: string | undefined;
21
+ baseURL: string;
22
+ fetch: Fetch;
23
+ /**
24
+ * Overrides the outgoing User-Agent header on the token exchange. When
25
+ * empty, sends an SDK-identified UA so the token endpoint's access logs
26
+ * identify the caller.
27
+ */
28
+ userAgent?: string | undefined;
29
+ };
30
+
31
+ /**
32
+ * Exchanges an external OIDC JWT for an Anthropic access token via the
33
+ * RFC 7523 jwt-bearer grant.
34
+ *
35
+ * Each invocation performs a fresh token exchange. Wrap in a
36
+ * {@link TokenCache} to avoid exchanging on every request.
37
+ *
38
+ * Federation grants do not return a refresh token — callers re-exchange
39
+ * their assertion on expiry.
40
+ */
41
+ export function oidcFederationProvider(config: OIDCFederationConfig): AccessTokenProvider {
42
+ return async () => {
43
+ requireSecureTokenEndpoint(config.baseURL);
44
+
45
+ const jwt = await config.identityTokenProvider();
46
+ // The token endpoint enforces a 16 KiB assertion limit; surface a clear
47
+ // client-side error so misconfigured projected-token sources are
48
+ // diagnosable without a server round-trip.
49
+ if (jwt.length > 16 * 1024) {
50
+ throw new WorkloadIdentityError(
51
+ `Identity token is ${Math.ceil(jwt.length / 1024)} KiB, exceeds the 16 KiB assertion limit`,
52
+ );
53
+ }
54
+
55
+ const body: Record<string, string> = {
56
+ grant_type: GRANT_TYPE_JWT_BEARER,
57
+ assertion: jwt,
58
+ federation_rule_id: config.federationRuleId,
59
+ organization_id: config.organizationId,
60
+ };
61
+ if (config.serviceAccountId) {
62
+ body['service_account_id'] = config.serviceAccountId;
63
+ }
64
+
65
+ const url = `${config.baseURL}${TOKEN_ENDPOINT}`;
66
+ let resp: Response;
67
+ try {
68
+ resp = await config.fetch(url, {
69
+ method: 'POST',
70
+ headers: {
71
+ 'Content-Type': 'application/json',
72
+ 'anthropic-beta': `${OAUTH_API_BETA_HEADER},${FEDERATION_BETA_HEADER}`,
73
+ 'User-Agent': config.userAgent || `anthropic-sdk-typescript/${VERSION} oidcFederationProvider`,
74
+ },
75
+ body: JSON.stringify(body),
76
+ });
77
+ } catch (err) {
78
+ throw new WorkloadIdentityError(`Failed to reach token endpoint ${url}: ${err}`);
79
+ }
80
+
81
+ const requestId = resp.headers.get('Request-Id');
82
+
83
+ if (!resp.ok) {
84
+ const text = await resp.text().catch(() => '');
85
+ const redacted = redactSensitive(text);
86
+ throw new WorkloadIdentityError(
87
+ `Token exchange failed with status ${resp.status}${
88
+ requestId ? ` (request-id ${requestId})` : ''
89
+ }: ${redacted}`,
90
+ resp.status,
91
+ redacted,
92
+ requestId,
93
+ );
94
+ }
95
+
96
+ const data = await parseTokenResponse(resp, requestId);
97
+ const expiresIn = Number(data.expires_in);
98
+ if (!Number.isFinite(expiresIn)) {
99
+ throw new WorkloadIdentityError(
100
+ `Token endpoint response missing required fields: ${JSON.stringify(redactSensitive(data))}`,
101
+ resp.status,
102
+ redactSensitive(data),
103
+ requestId,
104
+ );
105
+ }
106
+
107
+ return {
108
+ token: data.access_token,
109
+ expiresAt: nowAsSeconds() + expiresIn,
110
+ };
111
+ };
112
+ }
@@ -0,0 +1,130 @@
1
+ import type { AccessToken, AccessTokenProvider } from './types';
2
+ import {
3
+ ADVISORY_REFRESH_BACKOFF_IN_SECONDS,
4
+ ADVISORY_REFRESH_THRESHOLD_IN_SECONDS,
5
+ MANDATORY_REFRESH_THRESHOLD_IN_SECONDS,
6
+ } from './types';
7
+ import { nowAsSeconds } from '../../internal/utils/time';
8
+
9
+ /**
10
+ * Wraps an {@link AccessTokenProvider} with two-tier proactive refresh
11
+ * and concurrent deduplication.
12
+ *
13
+ * Refresh policy on each {@link getToken} call:
14
+ *
15
+ * - No cached token → call provider (blocking), cache, return.
16
+ * - Cached with `expiresAt == null` → return cached forever.
17
+ * - More than 120s remaining → return cached.
18
+ * - 30–120s remaining (advisory window) → return stale token immediately,
19
+ * kick off background refresh. On failure, log and keep stale.
20
+ * - Less than 30s remaining or expired (mandatory) → block and refresh.
21
+ * On failure, throw.
22
+ *
23
+ * Concurrent mandatory callers coalesce into a single provider call.
24
+ */
25
+ export class TokenCache {
26
+ private provider: AccessTokenProvider;
27
+ private cached: AccessToken | null = null;
28
+ private pendingRefresh: Promise<AccessToken> | null = null;
29
+ private nextForce = false;
30
+ private lastAdvisoryError = 0;
31
+ private onAdvisoryRefreshError: ((err: unknown) => void) | undefined;
32
+
33
+ constructor(provider: AccessTokenProvider, onAdvisoryRefreshError?: (err: unknown) => void) {
34
+ this.provider = provider;
35
+ this.onAdvisoryRefreshError = onAdvisoryRefreshError;
36
+ }
37
+
38
+ async getToken(): Promise<string> {
39
+ const force = this.nextForce;
40
+ this.nextForce = false;
41
+ const cached = this.cached;
42
+
43
+ if (force || cached == null) {
44
+ const token = await this.refresh(force);
45
+ return token.token;
46
+ }
47
+
48
+ if (cached.expiresAt == null) {
49
+ return cached.token;
50
+ }
51
+
52
+ const remaining = cached.expiresAt - nowAsSeconds();
53
+
54
+ if (remaining > ADVISORY_REFRESH_THRESHOLD_IN_SECONDS) {
55
+ return cached.token;
56
+ }
57
+
58
+ if (remaining > MANDATORY_REFRESH_THRESHOLD_IN_SECONDS) {
59
+ this.backgroundRefresh();
60
+ return cached.token;
61
+ }
62
+
63
+ const token = await this.refresh();
64
+ return token.token;
65
+ }
66
+
67
+ /**
68
+ * Clears the cached token and marks the next {@link getToken} as a forced
69
+ * refresh, so the underlying provider bypasses any on-disk freshness check.
70
+ * Called after a 401 — the server has just told us the token is bad even
71
+ * if its `expires_at` still looks fresh.
72
+ */
73
+ invalidate(): void {
74
+ this.cached = null;
75
+ this.nextForce = true;
76
+ }
77
+
78
+ /**
79
+ * Mandatory refresh. Joins any in-flight refresh unless forced — a forced
80
+ * refresh must not coalesce into a non-forced one that may re-serve the
81
+ * same stale disk token.
82
+ */
83
+ private refresh(force = false): Promise<AccessToken> {
84
+ if (this.pendingRefresh && !force) {
85
+ return this.pendingRefresh;
86
+ }
87
+ return this.doRefresh(force);
88
+ }
89
+
90
+ /**
91
+ * Advisory background refresh. Shares the same in-flight promise as
92
+ * mandatory refreshes for deduplication, but swallows errors so the
93
+ * stale cached token keeps being served. Backs off for
94
+ * {@link ADVISORY_REFRESH_BACKOFF_IN_SECONDS} after a failure so an
95
+ * outage during the advisory window doesn't hammer the token endpoint.
96
+ */
97
+ private backgroundRefresh(): void {
98
+ if (this.pendingRefresh) {
99
+ return;
100
+ }
101
+ if (nowAsSeconds() - this.lastAdvisoryError < ADVISORY_REFRESH_BACKOFF_IN_SECONDS) {
102
+ return;
103
+ }
104
+ this.doRefresh().catch((err) => {
105
+ this.lastAdvisoryError = nowAsSeconds();
106
+ // Advisory failure: keep serving the stale cached token, but surface
107
+ // the error to the caller-provided hook so it can be logged.
108
+ this.onAdvisoryRefreshError?.(err);
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Core refresh. Sets {@link pendingRefresh} so concurrent callers
114
+ * (both advisory and mandatory) coalesce into a single provider call.
115
+ */
116
+ private doRefresh(force = false): Promise<AccessToken> {
117
+ this.pendingRefresh = this.provider(force ? { forceRefresh: true } : undefined).then(
118
+ (token) => {
119
+ this.cached = token;
120
+ this.pendingRefresh = null;
121
+ return token;
122
+ },
123
+ (err) => {
124
+ this.pendingRefresh = null;
125
+ throw err;
126
+ },
127
+ );
128
+ return this.pendingRefresh;
129
+ }
130
+ }
@@ -0,0 +1,295 @@
1
+ import { AnthropicError } from '../../core/error';
2
+
3
+ export type AccessToken = {
4
+ token: string;
5
+ /** Unix epoch seconds. `null` means no expiry (cache forever). */
6
+ expiresAt: number | null;
7
+ };
8
+
9
+ /**
10
+ * Mints or returns a cached access token.
11
+ *
12
+ * The optional `opts.forceRefresh` flag, set by {@link TokenCache.invalidate}
13
+ * after a 401, tells providers with on-disk caches (user_oauth, cachedExchange)
14
+ * to bypass their freshness short-circuit and always fetch fresh. Providers
15
+ * without a cache can ignore it.
16
+ */
17
+ export type AccessTokenProvider = (opts?: { forceRefresh?: boolean }) => Promise<AccessToken>;
18
+
19
+ export type IdentityTokenProvider = () => string | Promise<string>;
20
+
21
+ export type CredentialResult = {
22
+ provider: AccessTokenProvider;
23
+ extraHeaders: Record<string, string>;
24
+ /**
25
+ * The `base_url` from the resolved config/profile, if any. The client
26
+ * applies this to outbound API requests when no explicit `baseURL` (constructor
27
+ * option or `ANTHROPIC_BASE_URL` env) was given, so a profile pointing at a
28
+ * non-default API host both mints its token against that host AND sends
29
+ * subsequent API requests there.
30
+ */
31
+ baseURL?: string | undefined;
32
+ };
33
+
34
+ /** Response body from `POST /v1/oauth/token`. */
35
+ export type TokenEndpointResponse = {
36
+ access_token?: string;
37
+ expires_in?: number;
38
+ refresh_token?: string;
39
+ };
40
+
41
+ export const GRANT_TYPE_JWT_BEARER = 'urn:ietf:params:oauth:grant-type:jwt-bearer';
42
+ export const GRANT_TYPE_REFRESH_TOKEN = 'refresh_token';
43
+ export const TOKEN_ENDPOINT = '/v1/oauth/token';
44
+
45
+ /**
46
+ * `anthropic-beta` value required on authenticated API requests using an
47
+ * OAuth bearer token, and on `refresh_token` grants against the token endpoint.
48
+ */
49
+ export const OAUTH_API_BETA_HEADER = 'oauth-2025-04-20';
50
+
51
+ /**
52
+ * `anthropic-beta` value required on jwt-bearer exchanges against the token
53
+ * endpoint. It routes the request to the federation service; it must NOT be
54
+ * sent on `refresh_token` grants, which are handled by a different backend.
55
+ */
56
+ export const FEDERATION_BETA_HEADER = 'oidc-federation-2026-04-01';
57
+
58
+ export const ADVISORY_REFRESH_THRESHOLD_IN_SECONDS = 120;
59
+ export const MANDATORY_REFRESH_THRESHOLD_IN_SECONDS = 30;
60
+ export const ADVISORY_REFRESH_BACKOFF_IN_SECONDS = 5;
61
+
62
+ const MAX_TOKEN_RESPONSE_BYTES = 1 << 20;
63
+
64
+ /**
65
+ * Rejects base URLs that would cause a JWT assertion or refresh token to be
66
+ * sent over cleartext HTTP. Loopback hosts are allowed for local development.
67
+ */
68
+ export function requireSecureTokenEndpoint(baseURL: string): void {
69
+ if (!baseURL) return;
70
+ let u: URL;
71
+ try {
72
+ u = new URL(baseURL);
73
+ } catch (err) {
74
+ throw new WorkloadIdentityError(`Invalid token endpoint base URL "${baseURL}": ${err}`);
75
+ }
76
+ if (u.protocol === 'https:') return;
77
+ // WHATWG URL.hostname returns bracketed IPv6 ("[::1]"); Go's net/url strips them.
78
+ const host = u.hostname.toLowerCase().replace(/^\[|\]$/g, '');
79
+ if (u.protocol === 'http:' && (host === 'localhost' || host === '127.0.0.1' || host === '::1')) {
80
+ return;
81
+ }
82
+ throw new WorkloadIdentityError(`Refusing to send credential over non-https token endpoint "${baseURL}"`);
83
+ }
84
+
85
+ /**
86
+ * Reads the response body as text, parses it as a token-endpoint JSON
87
+ * response, validates `access_token` is present, and rejects a non-Bearer
88
+ * `token_type` when one is provided. Reads at most
89
+ * {@link MAX_TOKEN_RESPONSE_BYTES} from the body stream.
90
+ */
91
+ export async function parseTokenResponse(
92
+ resp: Response,
93
+ requestId: string | null,
94
+ ): Promise<TokenEndpointResponse & { access_token: string }> {
95
+ const text = await readLimitedText(resp);
96
+ let data: TokenEndpointResponse & { token_type?: string };
97
+ try {
98
+ data = JSON.parse(text);
99
+ } catch {
100
+ throw new WorkloadIdentityError(
101
+ `Token endpoint returned non-JSON response (status ${resp.status})`,
102
+ resp.status,
103
+ redactSensitive(text),
104
+ requestId,
105
+ );
106
+ }
107
+ if (!data.access_token) {
108
+ throw new WorkloadIdentityError(
109
+ `Token endpoint response missing access_token: ${JSON.stringify(redactSensitive(data))}`,
110
+ resp.status,
111
+ redactSensitive(data),
112
+ requestId,
113
+ );
114
+ }
115
+ if (data.token_type && data.token_type.toLowerCase() !== 'bearer') {
116
+ throw new WorkloadIdentityError(
117
+ `Token endpoint response: unsupported token_type "${data.token_type}" (want Bearer)`,
118
+ resp.status,
119
+ redactSensitive(data),
120
+ requestId,
121
+ );
122
+ }
123
+ return data as TokenEndpointResponse & { access_token: string };
124
+ }
125
+
126
+ const MAX_ERROR_BODY_CHARS = 2000;
127
+ // RFC 6749 §5.2 standard error-response fields. Anything else in a token
128
+ // endpoint error body is potentially echoed input (assertion, refresh_token,
129
+ // access_token, …) and is dropped rather than allowlisted-with-exceptions.
130
+ const SAFE_ERROR_KEYS = new Set(['error', 'error_description', 'error_uri']);
131
+
132
+ /**
133
+ * Returns a redacted copy of a token-endpoint error body for safe inclusion
134
+ * in an exception. Strings are truncated; objects keep only the RFC 6749
135
+ * §5.2 error fields.
136
+ */
137
+ export function redactSensitive(body: unknown): unknown {
138
+ if (body == null) return body;
139
+ if (typeof body === 'string') {
140
+ let parsed: unknown;
141
+ try {
142
+ parsed = JSON.parse(body);
143
+ } catch {
144
+ if (body.length <= MAX_ERROR_BODY_CHARS) return body;
145
+ return body.slice(0, MAX_ERROR_BODY_CHARS) + `... <${body.length - MAX_ERROR_BODY_CHARS} more chars>`;
146
+ }
147
+ return JSON.stringify(redactSensitive(parsed));
148
+ }
149
+ if (typeof body === 'object' && !Array.isArray(body)) {
150
+ const out: Record<string, unknown> = {};
151
+ for (const [k, v] of Object.entries(body)) {
152
+ if (SAFE_ERROR_KEYS.has(k)) out[k] = v;
153
+ }
154
+ return out;
155
+ }
156
+ return null;
157
+ }
158
+
159
+ /**
160
+ * Best-effort safety check on a credentials file before reading it.
161
+ *
162
+ * On POSIX: resolves symlinks (so containerized deployments that mount the
163
+ * credential as a symlink to a tmpfs-backed file keep working), then rejects
164
+ * the resolved target if it is group- or world- readable or writable. A uid
165
+ * mismatch on the resolved target is surfaced via `onWarn` since
166
+ * root-written/app-read is common in init-container setups. No-op on Windows.
167
+ */
168
+ export async function checkCredentialsFileSafety(
169
+ path: string,
170
+ onWarn: (msg: string) => void = (m) => console.warn(`anthropic-sdk: ${m}`),
171
+ ): Promise<void> {
172
+ if (typeof process === 'undefined' || process.platform === 'win32') return;
173
+ const fs = await import('node:fs');
174
+ let resolved = path;
175
+ let st;
176
+ try {
177
+ resolved = await fs.promises.realpath(path);
178
+ st = await fs.promises.stat(resolved);
179
+ } catch {
180
+ return; // ENOENT etc — let the subsequent read surface a precise error
181
+ }
182
+ const mode = st.mode & 0o777;
183
+ // 0o022 = group/world write; 0o044 = group/world read.
184
+ if (mode & 0o022) {
185
+ throw new WorkloadIdentityError(
186
+ `Credentials file at ${resolved} is group/world-writable (mode 0o${mode.toString(8)}); ` +
187
+ `this allows other local users to plant tokens. Run \`chmod 600 ${resolved}\`.`,
188
+ );
189
+ }
190
+ if (mode & 0o044) {
191
+ throw new WorkloadIdentityError(
192
+ `Credentials file at ${resolved} is group/world-readable (mode 0o${mode.toString(8)}); ` +
193
+ `run \`chmod 600 ${resolved}\` before retrying.`,
194
+ );
195
+ }
196
+ if (typeof process.getuid === 'function' && st.uid !== process.getuid()) {
197
+ onWarn(
198
+ `credentials file at ${resolved} is owned by uid ${
199
+ st.uid
200
+ } (current process uid ${process.getuid()}); ` + `verify this is intentional.`,
201
+ );
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Atomically writes JSON to `targetPath` via a `.tmp` sibling + rename,
207
+ * with fsync on the file and (best-effort) on the parent directory.
208
+ * Creates the parent directory with mode 0700 and the file with mode 0600.
209
+ */
210
+ export async function writeCredentialsFileAtomic(targetPath: string, data: unknown): Promise<void> {
211
+ const fs = await import('node:fs');
212
+ const path = await import('node:path');
213
+ const dir = path.dirname(targetPath);
214
+ await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
215
+ // Unique temp name avoids two concurrent writers (different processes or
216
+ // SDK instances) racing on the same '.tmp' sibling and corrupting each
217
+ // other's bytes mid-write before the rename.
218
+ const tmpPath = `${targetPath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
219
+ try {
220
+ const fh = await fs.promises.open(tmpPath, 'w', 0o600);
221
+ try {
222
+ await fh.writeFile(JSON.stringify(data, null, 2));
223
+ await fh.sync();
224
+ } finally {
225
+ await fh.close();
226
+ }
227
+ await fs.promises.rename(tmpPath, targetPath);
228
+ } catch (err) {
229
+ // Don't leak the temp file if anything between create and rename failed.
230
+ await fs.promises.unlink(tmpPath).catch(() => {});
231
+ throw err;
232
+ }
233
+ // fsync the parent directory so the rename survives a crash.
234
+ try {
235
+ const dirFh = await fs.promises.open(dir, 'r');
236
+ try {
237
+ await dirFh.sync();
238
+ } finally {
239
+ await dirFh.close();
240
+ }
241
+ } catch {
242
+ // Directory fsync is best-effort (unsupported on some platforms, e.g. Windows).
243
+ }
244
+ }
245
+
246
+ async function readLimitedText(resp: Response): Promise<string> {
247
+ if (!resp.body) {
248
+ return '';
249
+ }
250
+ const reader = resp.body.getReader();
251
+ const chunks: Uint8Array[] = [];
252
+ let received = 0;
253
+ for (;;) {
254
+ const { done, value } = await reader.read();
255
+ if (done) break;
256
+ if (received + value.length > MAX_TOKEN_RESPONSE_BYTES) {
257
+ const remaining = MAX_TOKEN_RESPONSE_BYTES - received;
258
+ if (remaining > 0) chunks.push(value.subarray(0, remaining));
259
+ await reader.cancel();
260
+ break;
261
+ }
262
+ chunks.push(value);
263
+ received += value.length;
264
+ }
265
+ let merged: Uint8Array;
266
+ if (chunks.length === 1) {
267
+ merged = chunks[0]!;
268
+ } else {
269
+ merged = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0));
270
+ let offset = 0;
271
+ for (const c of chunks) {
272
+ merged.set(c, offset);
273
+ offset += c.length;
274
+ }
275
+ }
276
+ return new TextDecoder('utf-8').decode(merged);
277
+ }
278
+
279
+ export class WorkloadIdentityError extends AnthropicError {
280
+ readonly statusCode: number | null;
281
+ readonly body: unknown;
282
+ readonly requestId: string | null;
283
+
284
+ constructor(
285
+ message: string,
286
+ statusCode: number | null = null,
287
+ body: unknown = null,
288
+ requestId: string | null = null,
289
+ ) {
290
+ super(message);
291
+ this.statusCode = statusCode;
292
+ this.body = body;
293
+ this.requestId = requestId;
294
+ }
295
+ }
@@ -0,0 +1,144 @@
1
+ import type { Fetch } from '../../internal/builtin-types';
2
+ import { CREDENTIALS_FILE_VERSION, type AnthropicCredentials } from '../../core/credentials';
3
+ import type { AccessTokenProvider } from './types';
4
+ import {
5
+ GRANT_TYPE_REFRESH_TOKEN,
6
+ MANDATORY_REFRESH_THRESHOLD_IN_SECONDS,
7
+ OAUTH_API_BETA_HEADER,
8
+ TOKEN_ENDPOINT,
9
+ WorkloadIdentityError,
10
+ checkCredentialsFileSafety,
11
+ parseTokenResponse,
12
+ redactSensitive,
13
+ requireSecureTokenEndpoint,
14
+ writeCredentialsFileAtomic,
15
+ } from './types';
16
+ import { nowAsSeconds } from '../../internal/utils/time';
17
+ import { VERSION } from '../../version';
18
+
19
+ export type UserOAuthConfig = {
20
+ credentialsPath: string;
21
+ clientId?: string | undefined;
22
+ baseURL: string;
23
+ fetch: Fetch;
24
+ userAgent?: string | undefined;
25
+ onSafetyWarning?: ((msg: string) => void) | undefined;
26
+ };
27
+
28
+ /**
29
+ * Reads a user-oauth credential file. Returns the cached access token while
30
+ * fresh; on expiry performs a `refresh_token` grant and writes the new
31
+ * tokens back to the credentials file (atomic replace, fsync'd).
32
+ *
33
+ * If `clientId` is empty, the access token is treated as static — the
34
+ * credentials file is read on every call but no refresh is attempted, and
35
+ * an expired token without a `refresh_token` raises.
36
+ */
37
+ export function userOAuthProvider(config: UserOAuthConfig): AccessTokenProvider {
38
+ return async (opts) => {
39
+ const fs = await import('node:fs');
40
+
41
+ await checkCredentialsFileSafety(config.credentialsPath, config.onSafetyWarning);
42
+
43
+ let raw: string;
44
+ try {
45
+ raw = await fs.promises.readFile(config.credentialsPath, 'utf-8');
46
+ } catch (err) {
47
+ throw new WorkloadIdentityError(`Credentials file not found at ${config.credentialsPath}: ${err}`);
48
+ }
49
+ let creds: AnthropicCredentials;
50
+ try {
51
+ creds = JSON.parse(raw);
52
+ } catch (err) {
53
+ throw new WorkloadIdentityError(
54
+ `Credentials file at ${config.credentialsPath} is not valid JSON: ${err}`,
55
+ );
56
+ }
57
+
58
+ const accessToken = creds.access_token;
59
+ if (!accessToken) {
60
+ throw new WorkloadIdentityError(
61
+ `Credentials file at ${config.credentialsPath} must include 'access_token'`,
62
+ );
63
+ }
64
+
65
+ // Return cached token if still fresh (or no expiry info), unless the
66
+ // caller is forcing a refresh after a 401 — then go straight to refresh
67
+ // even if the file's expires_at still looks valid.
68
+ const expiresAt = creds.expires_at;
69
+ if (
70
+ !opts?.forceRefresh &&
71
+ (expiresAt == null || nowAsSeconds() < expiresAt - MANDATORY_REFRESH_THRESHOLD_IN_SECONDS)
72
+ ) {
73
+ return { token: accessToken, expiresAt: expiresAt ?? null };
74
+ }
75
+
76
+ const refreshToken = creds.refresh_token;
77
+ if (!config.clientId || !refreshToken) {
78
+ throw new WorkloadIdentityError(
79
+ `Access token at ${config.credentialsPath} has expired and no refresh is available ` +
80
+ `(client_id ${config.clientId ? 'set' : 'empty'}, refresh_token ${refreshToken ? 'set' : 'empty'})`,
81
+ );
82
+ }
83
+
84
+ requireSecureTokenEndpoint(config.baseURL);
85
+
86
+ const body: Record<string, string> = {
87
+ grant_type: GRANT_TYPE_REFRESH_TOKEN,
88
+ refresh_token: refreshToken,
89
+ client_id: config.clientId,
90
+ };
91
+
92
+ const url = `${config.baseURL}${TOKEN_ENDPOINT}`;
93
+ let resp: Response;
94
+ try {
95
+ resp = await config.fetch(url, {
96
+ method: 'POST',
97
+ headers: {
98
+ 'Content-Type': 'application/json',
99
+ 'anthropic-beta': OAUTH_API_BETA_HEADER,
100
+ 'User-Agent': config.userAgent || `anthropic-sdk-typescript/${VERSION} userOAuthProvider`,
101
+ },
102
+ body: JSON.stringify(body),
103
+ });
104
+ } catch (err) {
105
+ throw new WorkloadIdentityError(`User OAuth refresh failed to reach token endpoint: ${err}`);
106
+ }
107
+
108
+ const requestId = resp.headers.get('Request-Id');
109
+
110
+ if (!resp.ok) {
111
+ const text = await resp.text().catch(() => '');
112
+ throw new WorkloadIdentityError(
113
+ `User OAuth refresh failed (HTTP ${resp.status}): ${redactSensitive(text)}`,
114
+ resp.status,
115
+ redactSensitive(text),
116
+ requestId,
117
+ );
118
+ }
119
+
120
+ const data = await parseTokenResponse(resp, requestId);
121
+ const expiresIn = Number(data.expires_in);
122
+ if (!Number.isFinite(expiresIn)) {
123
+ throw new WorkloadIdentityError(
124
+ `User OAuth refresh response missing or invalid expires_in: ${JSON.stringify(redactSensitive(data))}`,
125
+ resp.status,
126
+ redactSensitive(data),
127
+ requestId,
128
+ );
129
+ }
130
+ const newExpiresAt = nowAsSeconds() + expiresIn;
131
+ const newRefreshToken = data.refresh_token || refreshToken;
132
+
133
+ await writeCredentialsFileAtomic(config.credentialsPath, {
134
+ ...creds,
135
+ version: CREDENTIALS_FILE_VERSION,
136
+ type: 'oauth_token',
137
+ access_token: data.access_token,
138
+ expires_at: newExpiresAt,
139
+ refresh_token: newRefreshToken,
140
+ });
141
+
142
+ return { token: data.access_token, expiresAt: newExpiresAt };
143
+ };
144
+ }
@@ -0,0 +1,3 @@
1
+ export { type AccessToken, type AccessTokenProvider } from './credentials/types';
2
+ export { type AnthropicConfig, type AuthenticationInfo, loadConfig } from '../core/credentials';
3
+ export { resolveCredentialsFromConfig } from './credentials/credential-chain';
@@ -214,7 +214,7 @@ export interface BetaCloudConfigParams {
214
214
  }
215
215
 
216
216
  /**
217
- * Unified Environment resource for both cloud and BYOC environments.
217
+ * Unified Environment resource for both cloud and self-hosted environments.
218
218
  */
219
219
  export interface BetaEnvironment {
220
220
  /**