Skip to content

Add client_claims support to confidential client and managed identity flows#937

Open
Robbie-Microsoft wants to merge 3 commits into
devfrom
rginsburg/client_claims
Open

Add client_claims support to confidential client and managed identity flows#937
Robbie-Microsoft wants to merge 3 commits into
devfrom
rginsburg/client_claims

Conversation

@Robbie-Microsoft

@Robbie-Microsoft Robbie-Microsoft commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Port of msal-dotnet PR 5999 (WithClaimsFromClient) into msal-python.

Adds a client_claims keyword argument that forwards client-originated claims (for example the network security perimeter xms_az_nwperimid claim) to ESTS / IMDS.

In .NET, WithClaimsFromClient is defined on the confidential-client builder base, so it applies to every confidential client flow. This port matches that scope:

Confidential client — msal/application.py

  • acquire_token_for_client
  • acquire_token_on_behalf_of (OBO)
  • acquire_token_by_user_federated_identity_credential (user FIC)
  • acquire_token_by_authorization_code
  • acquire_token_silent / acquire_token_silent_with_error (cache-read isolation + refresh-token request merge)

Managed identity — msal/managed_identity.py

  • ManagedIdentityClient.acquire_token_for_client (IMDS / Azure VM source only)

Key difference from claims_challenge

Unlike claims_challenge (server-issued, which bypasses the cache), client_claims tokens are cached, and the cache entry is keyed on the claims value. This reuses the existing _compute_ext_cache_key cache-isolation mechanism (the same pattern as fmi_path): claims stays excluded from the extended cache key, while client_claims participates in it. Isolation is bidirectional — a request carrying client_claims never reads a plain cached token, and a plain request never reads a client_claims token.

Naming: per-request client_claims vs the constructor client_claims

ClientApplication.__init__ already has an unrelated client_claims parameter — a dict of extra claims signed into the client-assertion JWT. The new per-request client_claims is a JSON string of claims forwarded in the token request. They are different concepts and never share a scope; the name is kept (mirroring the "claims from client" intent), and both docstrings now carry cross-referencing notes to disambiguate them.

Changes

Area Change
msal/token_cache.py New _parse_claims_or_raise, _deep_merge_dict, _merge_claims helpers. _parse_claims_or_raise raises a friendly ValueError for both malformed JSON and non-string input (it catches the TypeError from json.loads too). claims stays excluded from the extended cache key; client_claims participates.
msal/oauth2cli/oauth2.py Strips the cache-key-only client_claims pseudo-parameter from the wire body, while preserving it for the cache-add event.
msal/application.py New shared _stash_client_claims() helper validates client_claims (must be a JSON string) and stashes it into the request data (so it contributes to the extended cache key and is merged into the OAuth claims parameter, then stripped from the wire). Used consistently by every confidential client flow above, including acquire_token_for_client. The silent path stashes client_claims for cache-read isolation and merges it into the refresh-token request; the RT-refresh path pops data once before the candidate-RT loop so the value applies across all candidate RTs. Docstrings disambiguate the per-request parameter from the constructor client_claims.
msal/managed_identity.py MI supports client_claims on the IMDS (Azure VM) source only (sent as the claims query parameter); other sources raise a clear error; on MSIv1 the claims JSON may contain only the xms_az_nwperimid key. Non-string client_claims is rejected with a ValueError, consistent with the confidential-client flows.

Behavior notes

  • The raw claims value is never echoed in validation error messages (it may be sensitive).
  • Different client_claims values produce separate cache entries — use stable, non-dynamic values to avoid unbounded cache growth.
  • client_claims merges with capability-derived claims and claims_challenge into a single claims parameter.
  • A non-string client_claims raises a ValueError on every flow (confidential client and managed identity).
  • No behavior change when client_claims is absent: the merge is a no-op and the wire/cache paths are unaffected.

Testing

Added unit tests covering each flow: wire shape (merged claims present, no client_claims body leak), input validation (non-string / invalid JSON, including non-str types), cache isolation (same value → cache hit; different value or plain request → isolated), plus the MI source restrictions and MSIv1 validation.

python -m pytest tests/test_token_cache.py tests/test_application.py tests/test_mi.py
# 199 passed

… flows

Port of msal-dotnet PR 5999 (WithClaimsFromClient). Adds a `client_claims`
keyword argument to `ConfidentialClientApplication.acquire_token_for_client`
and `ManagedIdentityClient.acquire_token_for_client` for forwarding
client-originated claims (e.g. the network security perimeter
`xms_az_nwperimid` claim) to ESTS/IMDS.

Unlike `claims_challenge` (server-issued, bypasses the cache), `client_claims`
tokens are cached and the cache entry is keyed on the claims value, reusing the
existing `_compute_ext_cache_key` mechanism (the `fmi_path` precedent).

- token_cache: add `_parse_claims_or_raise`, `_deep_merge_dict`, `_merge_claims`
  helpers; `claims` stays excluded from the ext cache key while `client_claims`
  participates in it.
- oauth2: strip the cache-key-only `client_claims` pseudo-parameter from the
  wire body while preserving it for the cache-add event.
- application: validate `client_claims`, merge it into the OAuth `claims`
  body parameter, and isolate the cache by claims value.
- managed_identity: support `client_claims` on the IMDS (Azure VM) source only
  (sent as the `claims` query parameter); other sources raise; MSIv1 restricts
  the claims JSON to only the `xms_az_nwperimid` key.

Adds unit tests covering cache isolation, wire shape, claim merging, source
restrictions, and MSIv1 validation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings June 26, 2026 18:24
@Robbie-Microsoft Robbie-Microsoft requested a review from a team as a code owner June 26, 2026 18:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds support for forwarding client-originated claims via a new client_claims argument in confidential-client and managed-identity client-credentials flows, while ensuring tokens remain cacheable and isolated by the claims value (distinct from server-issued claims_challenge behavior).

Changes:

  • Introduces shared claims parsing/merging helpers and uses client_claims to participate in the extended cache key (while keeping claims excluded).
  • Ensures client_claims is merged into the OAuth claims parameter but stripped from the actual HTTP request body (cache-key-only pseudo-parameter).
  • Adds managed identity support for client_claims on IMDS/Azure VM only (including MSIv1 validation), with unit tests covering caching and wire-shape behaviors.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
msal/token_cache.py Adds claims helpers and leverages existing ext-cache-key mechanism to isolate cached tokens by client_claims.
msal/oauth2cli/oauth2.py Prevents client_claims from being sent on the wire while preserving it for cache add events.
msal/application.py Adds client_claims to CCA acquire-token-for-client flow, validates/merges claims, and isolates cache entries by claims value.
msal/managed_identity.py Adds client_claims support for IMDS/Azure VM only, isolates cache by claims, and validates MSIv1 claim constraints.
tests/test_token_cache.py Adds unit tests for cache-key isolation and claims helper behavior.
tests/test_mi.py Adds managed-identity unit tests for IMDS forwarding, cache isolation, and unsupported source errors.
tests/test_application.py Adds CCA unit tests for wire shape, merging behavior, and cache isolation with client_claims.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread msal/managed_identity.py
Comment thread msal/application.py
… code, silent)

Phase 1 added client_claims to acquire_token_for_client. This extends it to the
remaining confidential client flows so client-originated claims are forwarded
and cache-isolated consistently, mirroring msal-dotnet PR 5999's
WithClaimsFromClient (which applies to all confidential client builders):

- acquire_token_on_behalf_of (OBO)
- acquire_token_by_user_federated_identity_credential (FIC)
- acquire_token_by_authorization_code
- acquire_token_silent / acquire_token_silent_with_error (cache-read isolation
  plus refresh-token request merge)

A shared _stash_client_claims() helper validates the value and stashes it into
the request data, so it (a) contributes to the extended cache key and (b) is
merged into the OAuth "claims" parameter while being stripped from the wire
body. Adds unit tests for each flow (wire shape, validation, cache isolation).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Comment thread msal/application.py
Comment thread msal/application.py Outdated
Comment thread msal/managed_identity.py
Comment thread msal/token_cache.py
- token_cache._parse_claims_or_raise now also catches TypeError (raised when
  the input is not a str/bytes) and surfaces the same friendly ValueError, so
  every caller behaves consistently regardless of the bad input's type.
- ManagedIdentityClient.acquire_token_for_client now rejects non-string
  client_claims with a ValueError (mirroring the confidential-client flows),
  preventing a raw TypeError leak and inconsistent extended-cache-key hashing.
- ConfidentialClientApplication.acquire_token_for_client now reuses the shared
  _stash_client_claims() helper instead of duplicating the validate-and-stash
  logic, removing the risk of the two paths diverging.
- Add cross-referencing docstring notes disambiguating the per-request
  client_claims (a JSON string forwarded in the request) from the pre-existing
  constructor client_claims (a dict of claims signed into the client-assertion
  JWT).
- Add unit tests for non-string client_claims on managed identity and for
  non-string inputs to _parse_claims_or_raise.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants