Skip to content

feat(validator): parse and expose On-Behalf-Of Token Exchange claims (RFC 8693) - #407

Open
developerkunal wants to merge 5 commits into
masterfrom
feat/obo-token-exchange-claims
Open

feat(validator): parse and expose On-Behalf-Of Token Exchange claims (RFC 8693)#407
developerkunal wants to merge 5 commits into
masterfrom
feat/obo-token-exchange-claims

Conversation

@developerkunal

Copy link
Copy Markdown
Contributor

📝 Checklist

  • All new/changed/fixed functionality is covered by tests (or N/A)
  • I have added documentation for all new/changed functionality (or N/A)

🔧 Changes

Adds first-class validation support for access tokens issued through On-Behalf-Of / Token Exchange (RFC 8693), for example when an MCP server exchanges an incoming user token for one scoped to a downstream API. This SDK validates and inspects such tokens; performing the exchange itself is done by the authorization server, not this middleware.

These claims are now parsed automatically during validation, so no extra configuration is required.

validator.RegisteredClaims (new fields, additive)

  • Act *Actor — the act (actor) claim chain per RFC 8693 §4.1
  • AuthorizedParty string — the azp claim
  • OrgID string — the org_id claim
  • OrgName string — the org_name claim

New validator.Actor type

Models the nested act claim: Subject (sub), optional Issuer (iss), and a nested Act *Actor for the prior actor.

New helpers on validator.ValidatedClaims

  • CurrentActor() string — the current actor (outermost act.sub), the client that performed the most recent exchange. Per RFC 8693 §4.1, this is the only actor value that may be used for access-control decisions.
  • DelegationChain() []string — the full actor chain ordered from current to original, intended for audit and logging only.
  • HasActor() bool

The API is shaped so the safe call (CurrentActor) is the obvious one, and DelegationChain is explicitly documented as audit-only, matching the RFC 8693 §4.1 guidance that nested actors must not drive authorization.

Delegation chains are limited to 5 levels; a token whose act claim nests more than 5 actors is rejected as an invalid_claims validation error.

All changes are additive. No existing fields, signatures, or behavior changed, and no new dependencies or network calls are introduced. Extraction of act, azp, org_id, and org_name is folded into the existing single payload decode alongside the cnf claim.

📚 References

🔬 Testing

  • Unit tests for the CurrentActor, DelegationChain, and HasActor helpers cover the no-actor, empty-subject, single-exchange, and chained-exchange cases (validator/claims_test.go).
  • Extraction tests cover a plain Bearer token (all new fields empty), a single-exchange token, organization claims, a chain at the maximum depth of 5 (accepted), a chain of 6 (rejected as invalid_claims), and a malformed token (validator/validator_test.go).
  • Full suite passes: go test ./... (865 tests), go vet ./... clean, gofmt clean.

…(RFC 8693)

Add first-class validation support for tokens issued via On-Behalf-Of / Token Exchange (RFC 8693). RegisteredClaims now includes the act (actor) chain, azp, org_id, and org_name, populated automatically during validation.

Add ValidatedClaims helpers CurrentActor(), DelegationChain(), and HasActor() that distinguish the current actor (for authorization, per RFC 8693 §4.1) from the informational delegation chain (for audit). Reject delegation chains deeper than 5 levels as malformed.
@developerkunal
developerkunal requested a review from a team as a code owner July 3, 2026 12:43
@codecov-commenter

codecov-commenter commented Jul 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.92308% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.51%. Comparing base (1cc975d) to head (8569339).

Files with missing lines Patch % Lines
validator/validator.go 95.65% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #407      +/-   ##
==========================================
+ Coverage   94.42%   94.51%   +0.08%     
==========================================
  Files          25       25              
  Lines        2154     2206      +52     
==========================================
+ Hits         2034     2085      +51     
- Misses         81       82       +1     
  Partials       39       39              

☔ 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.

The cnf claim is now extracted in extractSupplementaryClaims alongside act, azp, and the organization claims, so the standalone extractConfirmationClaim method is unused and flagged by the linter. Remove it and fold its cnf test coverage into the extractSupplementaryClaims tests.
Comment thread validator/validator.go
Comment thread validator/validator.go Outdated
return payload.Cnf, nil
// Enforce the RFC 8693 delegation chain depth limit. Depth counts the
// number of nested actors; a chain deeper than the limit is malformed.
if actorChainDepth(payload.Act) > maxActorChainDepth {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This rejects any token whose act chain nests deeper than 5, at verification time. Two things :

The requirements say generic verification shouldn't fail because of the act claim, and the 5-level cap is described as something the authorization server enforces when it issues the token, not something the verifier re-checks. So a chain deep enough to trip this could only have come from a trusted issuer that already applies the limit.

Is the verifier-side rejection deliberate, or should we let the issuer own that limit? And if we do want to keep a ceiling, would a configurable option (defaulting to 5) sit better than a fixed constant?

@developerkunal developerkunal Jul 22, 2026

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 original intent was a defensive backstop at verification time, but the 5-level cap is really the authorization server's responsibility at issuance, and a chain that deep could only have come from a trusted issuer that already applies the limit. Hardcoding it as a constant also gave callers no way to adjust it.

We made it configurable instead of removing it. WithMaxActorChainDepth defaults to 5 to match the Auth0 issuer limit, so callers who trust their issuer can raise it while the default keeps the check for everyone else. It is positive only and errors at construction for zero or negative values, matching the other options in this package.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The configurable option helps, but I want to check the direction: the spec says 5 is the cap and the issuer enforces it, so what's the case for letting a caller set the limit above 5?

Lowering it to be stricter makes sense, but raising it seems to allow chains the spec says can't legitimately be issued. If the intent is to support non-Auth0 issuers with different rules that's reasonable, it just feels like it should be a deliberate, documented choice rather than something the option quietly allows. WDYT?

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.

Yes, my initial understanding was that the limit of 5 was a general limit, so I hardcoded it based on the Auth0 behavior. After your comment, I realized that this is an Auth0 specific limit and that other issuers may have different requirements.

That's why I made it configurable, so users can set the limit based on their own issuer's behavior and use case, rather than assuming every issuer follows Auth0's limit.

Comment thread validator/claims.go
Comment thread README.md Outdated
Comment thread validator/validator_test.go
- Decode cnf and act independently so a malformed act can no longer
  drop the cnf claim and silently downgrade a DPoP-bound token
- Add WithMaxActorChainDepth option (positive-only, default 5) so the
  delegation chain depth limit is configurable instead of a fixed const
- Stop DelegationChain at the first empty subject so it agrees with
  HasActor and CurrentActor on what an empty subject means
- Align docs and comments with the invalid_claims error actually returned
- Add end-to-end ValidateToken tests for actor/org/azp, over-depth
  rejection, configured depth, and cnf survival with a malformed act
Comment thread validator/claims.go Outdated
// entries.
func (v *ValidatedClaims) DelegationChain() []string {
var chain []string
for a := v.RegisteredClaims.Act; a != nil && a.Subject != ""; a = a.Act {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This stops the walk the moment it hits an actor with an empty sub, and returns whatever it collected up to that point with no error.

An empty subject as end-of-chain should be surfaced as an error

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.

Pushed the changes in latest commit.

developerkunal and others added 2 commits July 23, 2026 22:07
…n error

Per RFC 8693 §4.1 generic verification must not fail on the act claim, so
an actor with an empty sub no longer needs handling at ValidateToken. Change
DelegationChain to return ([]string, error): it walks the full chain and
returns ErrMalformedDelegationChain along with the subjects collected so far
when an actor has an empty sub, instead of silently truncating. HasActor and
CurrentActor keep their existing empty-sub semantics.
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.

3 participants