Skip to content

feat: Adds On-Behalf of Token Exchange support - #1094

Open
kailash-b wants to merge 2 commits into
masterfrom
feat/SDK-8434
Open

feat: Adds On-Behalf of Token Exchange support#1094
kailash-b wants to merge 2 commits into
masterfrom
feat/SDK-8434

Conversation

@kailash-b

Copy link
Copy Markdown
Contributor

Changes

This PR adds On-Behalf-Of (OBO) Token Exchange support to the Authentication API client, letting a service (for example, an MCP server) exchange an incoming user access token for a short-lived, audience-scoped access token that preserves the user's identity and actor attribution.

  • Adds a new GetTokenOnBehalfOfAsync method to the Authentication API client that performs the exchange. The caller supplies the incoming user token, the target audience, and optional scope/organization; the grant type and subject-token type are set internally.
  • The response exposes helpers to read the actor information from the exchanged token: one for the current actor (the value to use for authorization decisions) and one for the full delegation chain (for audit/logging only).
  • Token validation is out of scope
  • Documentation and a usage example have been added to README.md and Examples.md.

References

Testing

  • Unit tests cover the exchange request (correct parameters sent), input validation, response mapping, and the actor helpers.

  • This change adds unit test coverage

  • This change adds integration test coverage

  • This change has been tested on the latest version of the platform/language or why not

Checklist

@kailash-b
kailash-b requested a review from a team as a code owner August 13, 2026 10:10
@codecov

codecov Bot commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 25.82%. Comparing base (3d6b36b) to head (606a094).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1094      +/-   ##
==========================================
+ Coverage   25.77%   25.82%   +0.04%     
==========================================
  Files        3209     3213       +4     
  Lines      151191   151283      +92     
  Branches    10220    10228       +8     
==========================================
+ Hits        38970    39063      +93     
+ Misses     109845   109844       -1     
  Partials     2376     2376              
Flag Coverage Δ
authIntTests 2.42% <100.00%> (+0.06%) ⬆️
mgmtIntTests 24.75% <0.00%> (-0.02%) ⬇️
unittests 0.21% <0.00%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

if (cache is null || !ReferenceEquals(cache.Token, token))
{
cache = new ActorCache(token, ActClaimReader.ReadActor(token));
Volatile.Write(ref actorCache, cache);

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.

There is a write-write race here when AccessToken is reassigned on one thread while another thread is inside GetActor().

Scenario: thread A snapshots token = "v1" and is about to write the cache. Thread B reassigns AccessToken = "v2", computes ActorCache("v2", actor_v2), and writes it. Then thread A resumes and overwrites with ActorCache("v1", actor_v1). The cache now holds the actor for the old token. A third concurrent caller will read the wrong actor for "v2" until the cache is invalidated on the next call.

The fix is to replace Volatile.Write with Interlocked.CompareExchange(ref actorCache, cache, null) - or a snapshot-based CAS - so only the first writer wins and a stale overwrite is silently discarded. The concurrent-read test covers a stable token, but there is no test for this concurrent-mutation path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The scenario is real, it is near impossible to occur given that this method is on a token response.
Also, there will be no scenario where a wrong actor can be read since we are checking that the token in the cache is matching the token in the request. The worst possible outcome is that there could be a recompute of the value for the token that gets over-ridden.
No changes required.

/// not contain a <c>sub</c>.
/// </summary>
[JsonPropertyName("sub")]
public string? Subject { get; set; }

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.

Subject and Act are both settable ({ get; set; }). GetDelegationChain() returns the same Actor instance that is held inside the cached ActorCache, so a caller can do response.GetDelegationChain()!.Subject = "something" and permanently corrupt the cached actor. The next call to GetCurrentActor() will then return the wrong value.

Since Actor is only ever constructed by JSON deserialization or by ActClaimReader, changing both properties to { get; init; } would make the type immutable without breaking anything. That way the cached instance is safe to hand out directly.

TokenType = response.TokenType,
ExpiresIn = response.ExpiresIn,
Scope = response.Scope,
IssuedTokenType = response.IssuedTokenType

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.

RefreshToken and IdToken from the inner AccessTokenResponse are not mapped here and are silently dropped. The PR description says OBO issues no refresh or ID token, which is correct today, so this is fine in practice.

Just worth a note: if the server ever returns an id_token in this flow, it will actually be validated inside the GetTokenAsync(TokenExchangeTokenRequest) call above (via AssertIdTokenValidIfExisting), but the caller will never see it because OnBehalfOfTokenResponse has no IdToken property. A quick comment or a doc note on the response class saying these fields are intentionally excluded would help future maintainers understand this was a deliberate choice and not an oversight.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is by design. The OBO flow will not issue id_token or refresh_token today and if at it does, there should be a conscious change from our side on the SDK to expose it rather expose it automatically.

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