Skip to content

Commit 119e123

Browse files
feat(client): allow targeting a workspace for OIDC federation token exchange
1 parent 637560c commit 119e123

5 files changed

Lines changed: 225 additions & 7 deletions

File tree

src/anthropic/lib/credentials/_chain.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
ENV_PROFILE,
1212
ENV_AUTH_TOKEN,
1313
ENV_CONFIG_DIR,
14+
ENV_WORKSPACE_ID,
1415
ENV_IDENTITY_TOKEN,
1516
ENV_ORGANIZATION_ID,
1617
ENV_FEDERATION_RULE_ID,
@@ -62,6 +63,10 @@ def _read_env_token() -> str:
6263
federation_rule_id=federation_rule_id,
6364
organization_id=organization_id,
6465
service_account_id=os.environ.get(ENV_SERVICE_ACCOUNT_ID),
66+
# Coerce empty string to None so a defaulted-but-empty CI variable
67+
# doesn't put ``"workspace_id": ""`` on the wire — matches the falsy
68+
# skip in :func:`._providers._fill_missing_from_env`.
69+
workspace_id=os.environ.get(ENV_WORKSPACE_ID) or None,
6570
scope=os.environ.get(ENV_SCOPE),
6671
)
6772
provider.bind_base_url(base_url)

src/anthropic/lib/credentials/_constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
ENV_FEDERATION_RULE_ID = "ANTHROPIC_FEDERATION_RULE_ID"
4949
ENV_ORGANIZATION_ID = "ANTHROPIC_ORGANIZATION_ID"
5050
ENV_SERVICE_ACCOUNT_ID = "ANTHROPIC_SERVICE_ACCOUNT_ID"
51+
ENV_WORKSPACE_ID = "ANTHROPIC_WORKSPACE_ID"
5152
ENV_SCOPE = "ANTHROPIC_SCOPE"
5253
ENV_BASE_URL = "ANTHROPIC_BASE_URL"
5354

src/anthropic/lib/credentials/_providers.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
ENV_CONFIG_DIR,
2222
TOKEN_ENDPOINT,
2323
DEFAULT_BASE_URL,
24+
ENV_WORKSPACE_ID,
2425
ENV_ORGANIZATION_ID,
2526
OAUTH_API_BETA_HEADER,
2627
ENV_FEDERATION_RULE_ID,
@@ -90,6 +91,7 @@ def fill(target: Dict[str, Any], key: str, env_var: str) -> None:
9091

9192
fill(config, "base_url", ENV_BASE_URL)
9293
fill(config, "organization_id", ENV_ORGANIZATION_ID)
94+
fill(config, "workspace_id", ENV_WORKSPACE_ID)
9395

9496
auth_type = auth.get("type")
9597
if auth_type == AUTH_TYPE_OIDC_FEDERATION:
@@ -254,8 +256,9 @@ def extra_headers(self) -> Dict[str, str]:
254256
"""
255257
config = self._load_config()
256258
headers: Dict[str, str] = {}
257-
# Federation tokens are workspace-scoped server-side; the header is
258-
# only meaningful for non-federation (user_oauth, external) profiles.
259+
# For federation profiles workspace_id is sent in the jwt-bearer
260+
# exchange body, not as a request header (the minted token is already
261+
# workspace-scoped, so the header would be ignored).
259262
if self._auth_block().get("type") != AUTH_TYPE_OIDC_FEDERATION:
260263
workspace_id = config.get("workspace_id")
261264
if workspace_id:
@@ -656,6 +659,7 @@ def _build_workload_delegate(self, auth: Dict[str, Any]) -> WorkloadIdentityCred
656659
federation_rule_id=federation_rule_id,
657660
organization_id=organization_id,
658661
service_account_id=auth.get("service_account_id"),
662+
workspace_id=self._config.get("workspace_id"),
659663
scope=auth.get("scope"),
660664
http_client=self._get_http_client(),
661665
)
@@ -813,6 +817,7 @@ def _build_workload_delegate(self, auth: Dict[str, Any]) -> WorkloadIdentityCred
813817
federation_rule_id=federation_rule_id,
814818
organization_id=organization_id,
815819
service_account_id=auth.get("service_account_id"),
820+
workspace_id=self._config.get("workspace_id"),
816821
scope=auth.get("scope"),
817822
http_client=self._get_http_client(),
818823
)

src/anthropic/lib/credentials/_workload.py

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,19 +64,26 @@ def _redact_body(body: Any) -> Any:
6464
return None
6565

6666

67-
def _raise_token_endpoint_error(resp: httpx.Response, *, message_prefix: str) -> None:
67+
def _raise_token_endpoint_error(resp: httpx.Response, *, message_prefix: str, hint: Optional[str] = None) -> None:
6868
"""Raise a redacted :class:`WorkloadIdentityError` from a non-200 token-endpoint response.
6969
7070
Shared between the jwt-bearer exchange path in this module and the
7171
refresh_token grant path in :mod:`_providers`.
72+
73+
``hint`` is an optional caller-supplied diagnostic appended verbatim to the
74+
error message (after the redacted body). Callers gate it on the response
75+
status and their own state — this helper does not inspect ``resp`` for it.
7276
"""
7377
try:
7478
payload: Any = resp.json()
7579
except ValueError:
7680
payload = resp.text
7781
redacted = _redact_body(payload)
82+
message = f"{message_prefix} (HTTP {resp.status_code}): {redacted}"
83+
if hint:
84+
message = f"{message} {hint}"
7885
raise WorkloadIdentityError(
79-
f"{message_prefix} (HTTP {resp.status_code}): {redacted}",
86+
message,
8087
status_code=resp.status_code,
8188
body=redacted,
8289
request_id=_request_id(resp),
@@ -128,6 +135,16 @@ class WorkloadIdentityCredentials:
128135
Args:
129136
organization_id: The organization's raw UUID string (organizations do
130137
not use tagged IDs).
138+
workspace_id: Optional ``wrkspc_*`` tagged ID, or the literal
139+
``"default"`` to scope the token to the organization's default
140+
workspace. When omitted the server picks the rule's sole enabled
141+
workspace, else the org default if the rule covers it. Required
142+
when the rule enables more than one non-default workspace, or to
143+
target a specific workspace other than the one the server would
144+
pick. The minted token is workspace-scoped: per-request workspace
145+
selection (the ``anthropic-workspace-id`` header) is not supported
146+
for federation tokens — switching workspaces requires a new token
147+
exchange with a different ``workspace_id``.
131148
"""
132149

133150
def __init__(
@@ -137,13 +154,15 @@ def __init__(
137154
federation_rule_id: str,
138155
organization_id: str,
139156
service_account_id: Optional[str] = None,
157+
workspace_id: Optional[str] = None,
140158
scope: Optional[str] = None,
141159
http_client: Optional[httpx.Client] = None,
142160
) -> None:
143161
self._identity_token_provider = identity_token_provider
144162
self._federation_rule_id = federation_rule_id
145163
self._organization_id = organization_id
146164
self._service_account_id = service_account_id
165+
self._workspace_id = workspace_id
147166
# Scope is informational only for federation: the server derives the
148167
# effective scope from the matching federation rule and the gateway
149168
# transform drops unknown body fields, so it is intentionally NOT sent
@@ -220,6 +239,8 @@ def __call__(self, *, force_refresh: bool = False) -> AccessToken:
220239
}
221240
if self._service_account_id is not None:
222241
body["service_account_id"] = self._service_account_id
242+
if self._workspace_id is not None:
243+
body["workspace_id"] = self._workspace_id
223244

224245
url = f"{self._base_url}{TOKEN_ENDPOINT}"
225246
try:
@@ -246,7 +267,21 @@ def __call__(self, *, force_refresh: bool = False) -> AccessToken:
246267
)
247268

248269
if resp.status_code >= 400:
249-
_raise_token_endpoint_error(resp, message_prefix="Token exchange failed")
270+
# A 401 is almost always a federation-rule mismatch. Point at the
271+
# rule and the Console auth-event log; when the caller hasn't pinned
272+
# a workspace, also surface the multi-workspace fix rather than
273+
# making them dig through docs.
274+
hint: Optional[str] = None
275+
if resp.status_code == 401:
276+
hint = "Ensure your federation rule matches your identity token. "
277+
if self._workspace_id is None:
278+
hint += (
279+
"If your federation rule is scoped to multiple workspaces, set the "
280+
"ANTHROPIC_WORKSPACE_ID environment variable, the 'workspace_id' "
281+
"config key, or the workspace_id= argument. "
282+
)
283+
hint += "View your authentication events in the Workload identity page of Claude Console for more details."
284+
_raise_token_endpoint_error(resp, message_prefix="Token exchange failed", hint=hint)
250285

251286
try:
252287
data = resp.json()
@@ -289,6 +324,7 @@ def exchange_federation_assertion(
289324
federation_rule_id: str,
290325
organization_id: str,
291326
service_account_id: Optional[str] = None,
327+
workspace_id: Optional[str] = None,
292328
base_url: Optional[str] = None,
293329
http_client: Optional[httpx.Client] = None,
294330
) -> AccessToken:
@@ -304,6 +340,7 @@ def exchange_federation_assertion(
304340
federation_rule_id=federation_rule_id,
305341
organization_id=organization_id,
306342
service_account_id=service_account_id,
343+
workspace_id=workspace_id,
307344
http_client=http_client,
308345
)
309346
if base_url is not None:

0 commit comments

Comments
 (0)