Skip to content
37 changes: 37 additions & 0 deletions docs/arch/17-token-exchange-delegation.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,43 @@ whether to grant the exchange, in this order:
4. **No binding.** If none of the above apply, the token carries no
verifiable client binding and the exchange is rejected.

## `actor_token` and `id_token`

RFC 8693 also defines an optional `actor_token`/`actor_token_type` pair,
which `resolveActorIdentity` (`handler.go`) supports for self-issued actor
tokens only — an `actor_token` validated against any other issuer is
rejected. Two claims on the `actor_token` matter, and they play different
roles:

- **`client_id`** is the authenticated-client binding: it must equal the
authenticated OAuth client ID (the client fosite already authenticated for
this request). This is the same role a `client_id` claim plays on a normal
issued token — it says which client the token was minted for.
- **`sub`** is the actor identity that flows into the delegated token's
`act.sub`, in the same slot the authenticated client ID would otherwise
occupy. It is not required to equal the client ID: this is what lets a
single OAuth client present a more specific actor identity (e.g. a
particular agent instance or delegate persona) than its own `client_id`.

Presenting a distinct `sub` this way does not itself grant any extra
privilege — the resulting actor identity still has to clear
`checkDelegationConsent` above like any other actor identity would (via
`may_act`, `ExternalActor`, the `client_id` binding, or the configured-
delegate exemption).

`actor_token_type` only accepts `access_token` or `jwt`; `id_token` is
rejected outright. Similarly, `subject_token_type` accepts `access_token` or
`jwt`, but not `id_token`: this embedded authorization-server endpoint does
not implement the XAA/ID-JAG profile and applies exactly one validation profile
to a subject or actor token (the self-issued/access-token one — see
`rejectIDTokenClaims`'s doc comment in `validator.go`), so accepting a
declared `subject_token_type`/`actor_token_type` of `id_token` without a
distinct validation profile behind it would silently ignore the semantic
differences between an ID token and an access token (e.g. an ID token's
`aud` names the relying-party client, not a resource). Rather than validate
an ID token as if it were an access token, the server declines the type
until a real ID-token validation profile is implemented.

### Delegate clients and self-issued token exchange

A configured delegate client can convert *any* self-issued ToolHive access
Expand Down
166 changes: 165 additions & 1 deletion pkg/authserver/delegate_client_runner_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,15 @@ func TestConfiguredDelegateClientTokenExchange(t *testing.T) {
wantStatus: http.StatusBadRequest,
wantError: "invalid_request",
},
{
name: "id_token_subject_token_type_rejected",
authenticate: func(_ *http.Request) {},
mutateRequest: func(values url.Values) {
values.Set("subject_token_type", oauthproto.TokenTypeIDToken)
},
wantStatus: http.StatusBadRequest,
wantError: "invalid_request",
},
}

t.Run("discovery advertises token exchange and client secret methods", func(t *testing.T) {
Expand Down Expand Up @@ -161,6 +170,112 @@ func TestConfiguredDelegateClientTokenExchange(t *testing.T) {
}
}

// TestConfiguredDelegateClientTokenExchange_WithActorToken proves that an
// explicit RFC 8693 actor_token composes correctly with the configured
// delegate-client relaxation instead of being tested in isolation from it.
//
// The discriminating case is the mismatched-actor_token one: a configured
// delegate client's blanket trust to exchange ANY self-issued subject token
// (see docs/arch/17-token-exchange-delegation.md, "Delegate clients and
// self-issued token exchange") must never be interpretable as also relaxing
// the actor_token binding check in resolveActorIdentity. The subject token
// deliberately uses a client_id the delegate relaxation WOULD otherwise
// excuse, so a wrongly-successful response here proves actor identity
// resolution was skipped, not merely that delegation consent was lenient.
func TestConfiguredDelegateClientTokenExchange_WithActorToken(t *testing.T) {
t.Parallel()

const originalClientID = "original-non-delegate-client"

tests := []struct {
name string
subjectClientID string // client_id claim baked into the subject token
actorTokenClient string // client_id claim of the actor_token (binding check)
actorTokenSub string // sub claim of the actor_token (asserted actor identity)
wantStatus int
wantError string
}{
{
// RFC 8693 §2.2.2 requires invalid_request when an actor token is
// unacceptable based on policy, including a client-ID binding mismatch.
name: "mismatched actor_token rejected before delegation consent is reached",
subjectClientID: originalClientID,
actorTokenClient: "someone-elses-client",
actorTokenSub: "someone-else",
wantStatus: http.StatusBadRequest,
wantError: "invalid_request",
},
{
name: "matching actor_token still succeeds via the delegate-client relaxation",
subjectClientID: originalClientID,
actorTokenClient: delegateClientID,
actorTokenSub: delegateClientID,
wantStatus: http.StatusOK,
},
{
// Delegate status must not change behavior when the relaxation isn't
// needed: the subject token's client_id already matches the
// authenticated client, so this succeeds on ordinary client_id
// binding, and verifyDelegatedToken's act.sub assertion below proves
// it produces the identical act shape either way.
name: "actor_token present but relaxation unneeded still succeeds",
subjectClientID: delegateClientID,
actorTokenClient: delegateClientID,
actorTokenSub: delegateClientID,
wantStatus: http.StatusOK,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

server, issuer, embedded := startConfiguredDelegateAuthServer(t)
subjectToken := signedSubjectTokenForClient(t, embedded, issuer, tt.subjectClientID)
actorToken := signedActorToken(t, embedded, issuer, tt.actorTokenClient, tt.actorTokenSub)

values := url.Values{
"grant_type": {tokenExchangeGrantType},
"subject_token": {subjectToken},
"subject_token_type": {oauthproto.TokenTypeAccessToken},
"actor_token": {actorToken},
"actor_token_type": {oauthproto.TokenTypeAccessToken},
"scope": {"openid profile"},
"audience": {delegateAudience},
"client_id": {delegateClientID},
"client_secret": {delegateClientSecret},
}

request, err := http.NewRequest(http.MethodPost, server.URL+"/oauth/token", strings.NewReader(values.Encode()))
require.NoError(t, err)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")

response, err := (&http.Client{Timeout: 10 * time.Second}).Do(request)
require.NoError(t, err)
t.Cleanup(func() {
_, _ = io.Copy(io.Discard, response.Body)
require.NoError(t, response.Body.Close())
})

var body map[string]any
require.NoError(t, json.NewDecoder(response.Body).Decode(&body))
require.Equal(t, tt.wantStatus, response.StatusCode, "token response: %v", body)
if tt.wantError != "" {
assert.Equal(t, tt.wantError, body["error"])
return
}

accessToken, ok := body["access_token"].(string)
require.True(t, ok)
// verifyDelegatedToken asserts act.sub == delegateClientID, which
// here also confirms actor_token's own claims never leak into act:
// RFC 8693 §4.1's act.sub must name the current actor precisely
// because downstream access-control decisions key off it.
verifyDelegatedToken(t, embedded, accessToken, issuer)
})
}
}

func assertConfiguredDelegateDiscovery(t *testing.T, serverURL string) {
t.Helper()

Expand Down Expand Up @@ -233,6 +348,19 @@ func signedDelegateSubjectToken(
issuer string,
) string {
t.Helper()
return signedSubjectTokenForClient(t, embedded, issuer, delegateClientID)
}

// signedSubjectTokenForClient mints a self-issued subject token whose
// "client_id" claim is the given client — used to exercise the configured
// delegate-client relaxation, which requires a subject token originally
// obtained by a DIFFERENT client than the one performing the exchange.
func signedSubjectTokenForClient(
t *testing.T,
embedded *authserverrunner.EmbeddedAuthServer,
issuer, clientID string,
) string {
t.Helper()

signingKey, err := embedded.KeyProvider().SigningKey(context.Background())
require.NoError(t, err)
Expand All @@ -250,13 +378,49 @@ func signedDelegateSubjectToken(
Expiry: jwt.NewNumericDate(now.Add(30 * time.Minute)),
IssuedAt: jwt.NewNumericDate(now),
}).Claims(map[string]any{
"client_id": delegateClientID,
"client_id": clientID,
"scope": "openid profile",
}).Serialize()
require.NoError(t, err)
return token
}

// signedActorToken mints a self-issued RFC 8693 actor_token with the given
// "client_id" and "sub" claims. resolveActorIdentity requires "client_id" to
// equal the authenticated client's ID (the client-ID binding check) or the
// exchange is rejected before delegation consent is ever consulted; "sub" is
// the asserted actor identity, which flows into the
// delegated token's act.sub and may legitimately differ from "client_id" —
// see TestConfiguredDelegateClientTokenExchange_WithActorToken.
func signedActorToken(
t *testing.T,
embedded *authserverrunner.EmbeddedAuthServer,
issuer, tokenClientID, sub string,
) string {
t.Helper()

signingKey, err := embedded.KeyProvider().SigningKey(context.Background())
require.NoError(t, err)
signer, err := jose.NewSigner(
jose.SigningKey{Algorithm: jose.SignatureAlgorithm(signingKey.Algorithm), Key: signingKey.Key},
(&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", signingKey.KeyID),
)
require.NoError(t, err)

now := time.Now()
token, err := jwt.Signed(signer).Claims(jwt.Claims{
Issuer: issuer,
Subject: sub,
Audience: jwt.Audience{delegateAudience},
Expiry: jwt.NewNumericDate(now.Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
}).Claims(map[string]any{
"client_id": tokenClientID,
}).Serialize()
require.NoError(t, err)
return token
}

func verifyDelegatedToken(t *testing.T, embedded *authserverrunner.EmbeddedAuthServer, token, issuer string) {
t.Helper()

Expand Down
1 change: 1 addition & 0 deletions pkg/authserver/server/tokenexchange/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ func Factory(
Config: config.Config,
},
validator: validator,
selfValidator: selfValidator,
delegationLifespan: delegationLifespan,
config: config.Config,
allowedAudiences: config.AllowedAudiences,
Expand Down
Loading
Loading