Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Change Log

## [Unreleased]

**Added**
- feat(validator): parse and expose On-Behalf-Of / Token Exchange claims per RFC 8693. `RegisteredClaims` now includes `Act` (actor chain), `AuthorizedParty` (`azp`), `OrgID`, and `OrgName`. New `ValidatedClaims` helpers `CurrentActor()`, `DelegationChain()`, and `HasActor()` distinguish the current actor (for authorization) from the informational delegation chain (for audit), and delegation chains deeper than 5 levels are rejected.

## [v3.2.0](https://github.com/auth0/go-jwt-middleware/tree/v3.2.0) (2026-05-15)
[Full Changelog](https://github.com/auth0/go-jwt-middleware/compare/v3.1.0...v3.2.0)

Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,46 @@ middleware, err := jwtmiddleware.New(

See the [DPoP examples](./examples/http-dpop-example) for complete working code.

### On-Behalf-Of / Token Exchange (RFC 8693)

When an access token is issued through [On-Behalf-Of Token Exchange (RFC 8693)](https://www.rfc-editor.org/rfc/rfc8693.html), for example an MCP server exchanging an incoming user token for one scoped to a downstream API, it carries an `act` (actor) claim describing who is acting on behalf of the user, along with `azp` and, for organization-bound tokens, `org_id`/`org_name`. These claims are parsed automatically during validation, so there is nothing extra to configure. This SDK validates and inspects such tokens; performing the exchange itself is done by your authorization server, not this middleware.

Per RFC 8693 Β§4.1, only the **current actor** (the outermost `act.sub`, the client that performed the most recent exchange) may be used for access-control decisions. The nested actors form a delegation chain that is informational only and intended for audit and logging. `ValidatedClaims` exposes helpers that keep this distinction clear:

```go
claims, err := jwtValidator.ValidateToken(ctx, tokenString)
if err != nil {
// handle validation error
}
validated := claims.(*validator.ValidatedClaims)

// The user the request is being made on behalf of.
userID := validated.RegisteredClaims.Subject // "auth0|user123"

// The current actor: the client that performed the token exchange.
// Use ONLY this for authorization decisions.
if actor := validated.CurrentActor(); actor != "" && !authorizedActors[actor] {
http.Error(w, "actor not authorized", http.StatusForbidden)
return
}

// The full delegation chain, ordered from current actor to the original
// client. For audit/logging only; never use nested actors for authorization.
// Per RFC 8693 Β§4.1 verification does not fail on the actor claim, so a
// malformed chain (an actor with an empty sub) is reported here as
// validator.ErrMalformedDelegationChain rather than at ValidateToken. The
// subjects gathered before the break are still returned.
chain, err := validated.DelegationChain() // ["mcp_server_client_id", "spa_client_id"]
if err != nil {
// audit: the delegation chain was truncated at a malformed actor
}

// Organization context is preserved on org-bound exchanged tokens.
orgID := validated.RegisteredClaims.OrgID
```

Delegation chains are limited to 5 levels by default; a token whose `act` claim nests more actors than the limit is rejected with an `invalid_claims` validation error. You can change the limit with `validator.WithMaxActorChainDepth`.

### Multiple Issuers (Multi-Tenant Support)

Accept JWTs from multiple issuers simultaneously - perfect for multi-tenant SaaS applications, domain migrations, or enterprise deployments.
Expand Down
102 changes: 102 additions & 0 deletions validator/claims.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,16 @@ package validator

import (
"context"
"errors"
)

// ErrMalformedDelegationChain is returned by ValidatedClaims.DelegationChain
// when the act (actor) claim contains an actor with an empty sub. Per RFC 8693
// Β§4.1, generic token verification does not fail on the actor claim, so this
// surfaces only when a caller walks the chain for audit rather than during
// ValidateToken. Match it with errors.Is.
var ErrMalformedDelegationChain = errors.New("validator: delegation chain contains an actor with an empty sub")

// ValidatedClaims is the struct that will be inserted into
// the context for the user. CustomClaims will be nil
// unless WithCustomClaims is passed to New.
Expand All @@ -26,6 +34,47 @@ type RegisteredClaims struct {
NotBefore int64 `json:"nbf,omitempty"`
IssuedAt int64 `json:"iat,omitempty"`
ID string `json:"jti,omitempty"`

// AuthorizedParty is the azp claim identifying the party the token was
// issued to. For tokens produced by On-Behalf-Of / Token Exchange
// (RFC 8693) it is the client that performed the exchange, and it
// typically matches the current actor's subject (Act.Subject).
AuthorizedParty string `json:"azp,omitempty"`

// OrgID is the org_id claim. When the exchanged token is organization
// bound, this carries the organization the request is scoped to.
OrgID string `json:"org_id,omitempty"`

// OrgName is the org_name claim, the human-readable organization name
// that accompanies OrgID when present.
OrgName string `json:"org_name,omitempty"`

// Act is the act (actor) claim as defined by RFC 8693 Β§4.1. It is
// populated for tokens issued via On-Behalf-Of / Token Exchange and is
// nil for ordinary Bearer tokens. Use ValidatedClaims.CurrentActor for
// access-control decisions; the nested chain is informational only.
Act *Actor `json:"act,omitempty"`
}

// Actor represents the act (actor) claim from RFC 8693 Β§4.1. It identifies a
// party that is acting on behalf of the token's subject. Actors nest through
// the Act field to describe a delegation chain, where the outermost actor is
// the current (most recent) actor and inner actors are prior parties.
//
// Per RFC 8693 Β§4.1, only the current actor (the outermost act.sub) may be
// used for access-control decisions. Nested actors are informational and are
// intended for audit and logging only.
type Actor struct {
// Subject is the actor's sub claim, identifying the acting party.
Subject string `json:"sub,omitempty"`

// Issuer is the actor's optional iss claim. RFC 8693 permits an actor to
// carry its own issuer; Auth0-issued tokens usually omit it.
Issuer string `json:"iss,omitempty"`

// Act is the prior actor in the delegation chain. It is informational
// only and MUST NOT be used for access-control decisions.
Act *Actor `json:"act,omitempty"`
}

// CustomClaims defines any custom data / claims wanted.
Expand Down Expand Up @@ -58,3 +107,56 @@ func (v *ValidatedClaims) GetConfirmationJKT() string {
func (v *ValidatedClaims) HasConfirmation() bool {
return v.ConfirmationClaim != nil && v.ConfirmationClaim.JKT != ""
}

// HasActor reports whether the token carries a usable act (actor) claim, which
// is the case for tokens issued via On-Behalf-Of / Token Exchange (RFC 8693).
// An actor whose sub is empty is treated as no actor, matching CurrentActor.
func (v *ValidatedClaims) HasActor() bool {
return v.RegisteredClaims.Act != nil && v.RegisteredClaims.Act.Subject != ""
}

// CurrentActor returns the subject of the current actor, i.e. the outermost
// act.sub. This identifies the party that performed the most recent token
// exchange and is the ONLY actor value that may be used for access-control
// decisions, per RFC 8693 Β§4.1. It returns an empty string when the token has
// no actor.
//
// A typical use is checking the current actor against an allowlist:
//
// if actor := claims.CurrentActor(); actor != "" && !authorized[actor] {
// // reject: acting client is not permitted
// }
func (v *ValidatedClaims) CurrentActor() string {
if v.RegisteredClaims.Act == nil {
return ""
}
return v.RegisteredClaims.Act.Subject
}

// DelegationChain returns the subjects of every actor in the delegation chain,
// ordered from the current actor to the original one. For a token exchanged
// user -> SPA -> MCP server it returns ["mcp_server_id", "spa_client_id"].
//
// This is intended for audit and logging only. Do NOT use nested actors for
// authorization decisions; per RFC 8693 Β§4.1 only CurrentActor may drive
// access control. It returns a nil chain and nil error when the token has no
// actor.
//
// Per RFC 8693 Β§4.1, generic token verification does not fail on the actor
// claim, so a malformed chain is not rejected during ValidateToken. Instead it
// is surfaced here: if any actor in the chain has an empty sub, DelegationChain
// returns the subjects collected up to that point along with
// ErrMalformedDelegationChain, so a caller walking the chain for audit learns
// the chain was truncated rather than silently receiving a short chain. Callers
// that only need the current actor for authorization should use CurrentActor
// and can ignore this error.
func (v *ValidatedClaims) DelegationChain() ([]string, error) {
var chain []string
for a := v.RegisteredClaims.Act; a != nil; a = a.Act {
if a.Subject == "" {
return chain, ErrMalformedDelegationChain
}
chain = append(chain, a.Subject)
Comment thread
kishore7snehil marked this conversation as resolved.
}
return chain, nil
}
87 changes: 87 additions & 0 deletions validator/claims_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestValidatedClaims_DPoPMethods(t *testing.T) {
Expand Down Expand Up @@ -58,6 +59,92 @@ func TestValidatedClaims_DPoPMethods(t *testing.T) {
})
}

func TestValidatedClaims_ActorHelpers(t *testing.T) {
t.Run("no actor", func(t *testing.T) {
claims := &ValidatedClaims{}
assert.False(t, claims.HasActor())
assert.Empty(t, claims.CurrentActor())

chain, err := claims.DelegationChain()
require.NoError(t, err)
assert.Nil(t, chain)
})

t.Run("actor with empty subject is not considered present", func(t *testing.T) {
claims := &ValidatedClaims{
RegisteredClaims: RegisteredClaims{Act: &Actor{}},
}
assert.False(t, claims.HasActor())
assert.Empty(t, claims.CurrentActor())

// An empty subject at the head of the chain is malformed: DelegationChain
// surfaces the error instead of silently returning an empty chain.
chain, err := claims.DelegationChain()
require.ErrorIs(t, err, ErrMalformedDelegationChain)
assert.Empty(t, chain)
})

t.Run("empty subject in the middle of the chain surfaces an error", func(t *testing.T) {
// Per RFC 8693 Β§4.1 verification does not fail on the actor claim, so the
// malformed chain is caught here rather than at ValidateToken. The
// subjects collected before the empty sub are returned alongside the error
// so a caller learns the chain was truncated.
claims := &ValidatedClaims{
RegisteredClaims: RegisteredClaims{
Act: &Actor{
Subject: "mcp_server_client_id",
Act: &Actor{Subject: "", Act: &Actor{Subject: "spa_client_id"}},
},
},
}

chain, err := claims.DelegationChain()
require.ErrorIs(t, err, ErrMalformedDelegationChain)
assert.Equal(t, []string{"mcp_server_client_id"}, chain)
})

t.Run("single exchange", func(t *testing.T) {
claims := &ValidatedClaims{
RegisteredClaims: RegisteredClaims{
Act: &Actor{
Subject: "mcp_server_client_id",
Act: &Actor{Subject: "spa_client_id"},
},
},
}

assert.True(t, claims.HasActor())
assert.Equal(t, "mcp_server_client_id", claims.CurrentActor())

chain, err := claims.DelegationChain()
require.NoError(t, err)
assert.Equal(t, []string{"mcp_server_client_id", "spa_client_id"}, chain)
})

t.Run("chained exchange preserves order from current to original", func(t *testing.T) {
claims := &ValidatedClaims{
RegisteredClaims: RegisteredClaims{
Act: &Actor{
Subject: "mcp_server_2_client_id",
Act: &Actor{
Subject: "mcp_server_1_client_id",
Act: &Actor{Subject: "spa_client_id"},
},
},
},
}

assert.Equal(t, "mcp_server_2_client_id", claims.CurrentActor())

chain, err := claims.DelegationChain()
require.NoError(t, err)
assert.Equal(t,
[]string{"mcp_server_2_client_id", "mcp_server_1_client_id", "spa_client_id"},
chain,
)
})
}

func TestDPoPProofClaims_GetterMethods(t *testing.T) {
t.Run("GetJTI returns the jti claim", func(t *testing.T) {
claims := &DPoPProofClaims{
Expand Down
62 changes: 55 additions & 7 deletions validator/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,15 +223,63 @@ The ValidatedClaims struct contains both registered and custom claims:
}

type RegisteredClaims struct {
Issuer string // iss
Subject string // sub
Audience []string // aud
ID string // jti
Expiry int64 // exp (Unix timestamp)
NotBefore int64 // nbf (Unix timestamp)
IssuedAt int64 // iat (Unix timestamp)
Issuer string // iss
Subject string // sub
Audience []string // aud
ID string // jti
Expiry int64 // exp (Unix timestamp)
NotBefore int64 // nbf (Unix timestamp)
IssuedAt int64 // iat (Unix timestamp)
AuthorizedParty string // azp
OrgID string // org_id
OrgName string // org_name
Act *Actor // act (RFC 8693 actor chain)
}

# On-Behalf-Of / Token Exchange (RFC 8693)

Tokens issued via On-Behalf-Of Token Exchange carry an act (actor) claim that
identifies the party acting on behalf of the subject, plus azp and, for
organization-bound tokens, org_id/org_name. These are populated automatically
during validation; no extra configuration is required.

Per RFC 8693 Β§4.1, only the current actor (the outermost act.sub) may be used
for access-control decisions. The nested chain is informational and intended
for audit and logging only. Use the helpers on ValidatedClaims to stay on the
right side of this rule:

claims, err := v.ValidateToken(ctx, tokenString)
if err != nil {
// handle validation error
}
validated := claims.(*validator.ValidatedClaims)

// The user the request is on behalf of.
userID := validated.RegisteredClaims.Subject

// The current actor: the client that performed the exchange.
// Use ONLY this for authorization decisions.
if actor := validated.CurrentActor(); actor != "" && !authorizedActors[actor] {
// reject: acting client is not permitted
}

// The full delegation chain, current-to-original, for audit logging only.
// Never use nested actors for authorization. Per RFC 8693 Β§4.1 verification
// does not fail on the actor claim, so a malformed chain (an actor with an
// empty sub) is surfaced here as ErrMalformedDelegationChain rather than at
// ValidateToken; the subjects gathered before the break are still returned.
chain, err := validated.DelegationChain()
if err != nil {
// audit: the chain was truncated at a malformed actor
}

// Organization context is preserved on org-bound tokens.
orgID := validated.RegisteredClaims.OrgID

Delegation chains are limited to 5 levels by default; a token whose act claim
nests more actors than the limit is rejected with an invalid_claims validation
error. The limit is configurable via WithMaxActorChainDepth.

# Error Handling

claims, err := v.ValidateToken(ctx, tokenString)
Expand Down
Loading
Loading