From fb5f250353f3a8fec8cdc0090bfa0db0e8199483 Mon Sep 17 00:00:00 2001 From: Oscar Sanderson Date: Sun, 4 Oct 2026 20:42:06 +0800 Subject: [PATCH] fix(client): apply every mTLS endpoint alias, whatever the reason for mutual TLS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 8705 §5: a client "intending to do mutual TLS (for OAuth client authentication and/or to acquire or use certificate-bound tokens) when making a request directly to the authorization server MUST use the alias URL of the endpoint", wherever the server advertises one. MTLSEndpoints split the aliases by purpose instead. ApplyForClientAuth moved Token, PAR and revocation, but not the CIBA backchannel authentication endpoint, where CIBA Core §7.1 requires the client to authenticate with its registered method, so a certificate-authenticated CIBA client sent its request to the plain endpoint. ApplyForSenderConstrain moved Token, CIBA and revocation, but not PAR. Both now apply every advertised alias (Token, PAR, backchannel authentication and revocation), the same way; Authorization, which the user agent calls, is never aliased. Callers keep calling whichever they did; a client doing both needs one call. The mTLS guide and the conformance client's comment say so. Co-Authored-By: Claude Opus 5.5 --- client/config.go | 87 +++++++++++++-------------- client/mtls_endpoints_test.go | 104 +++++++++++++-------------------- cmd/conformance-client/main.go | 7 +-- docs/guides/mtls.md | 7 ++- 4 files changed, 88 insertions(+), 117 deletions(-) diff --git a/client/config.go b/client/config.go index 505993d6..6cf0d2df 100644 --- a/client/config.go +++ b/client/config.go @@ -227,58 +227,55 @@ type MTLSEndpoints struct { Revocation fapi.URL } -// ApplyForSenderConstrain overrides endpoints' Token, -// BackchannelAuthentication and Revocation fields with m's own -// advertised aliases, -// wherever m advertises one (RFC 8705 §5) — for a -// Config.SenderConstrain == SenderConstrainMTLS client, whose -// certificate-bound access tokens may need to be requested, and CIBA -// polls sent, on a separate mTLS-only origin than the server's plain -// endpoints. m may be nil (the server never advertised -// mtls_endpoint_aliases at all); ApplyForSenderConstrain then leaves -// endpoints untouched and reports false. That's not an error: the -// aliases are optional (RFC 8705 §5), and a server that advertises none -// accepts mutual TLS at its ordinary endpoints, which is where a -// SenderConstrainMTLS client then sends these calls. +// ApplyForSenderConstrain overrides endpoints with every alias m +// advertises, for a Config.SenderConstrain == SenderConstrainMTLS client. +// RFC 8705 §5: a client "intending to do mutual TLS (for OAuth client +// authentication and/or to acquire or use certificate-bound tokens) when +// making a request directly to the authorization server MUST use the +// alias URL of the endpoint", for whichever of its endpoints m advertises +// one: Token, PushedAuthorizationRequest, BackchannelAuthentication and +// Revocation. Authorization is never aliased: the user agent, not the +// client, makes that request. +// +// m may be nil (the server never advertised mtls_endpoint_aliases at +// all); ApplyForSenderConstrain then leaves endpoints untouched and +// reports false. That's not an error: the aliases are optional, and a +// server that advertises none accepts mutual TLS at its ordinary +// endpoints. ApplyForClientAuth does exactly the same: §5 doesn't +// distinguish the two reasons for doing mutual TLS. func (m *MTLSEndpoints) ApplyForSenderConstrain(endpoints *Endpoints) bool { - if m == nil { - return false - } - if !m.Token.IsZero() { - endpoints.Token = m.Token - } - if !m.BackchannelAuthentication.IsZero() { - endpoints.BackchannelAuthentication = m.BackchannelAuthentication - } - if !m.Revocation.IsZero() { - endpoints.Revocation = m.Revocation - } - return true + return m.apply(endpoints) } -// ApplyForClientAuth is ApplyForSenderConstrain's own counterpart for -// RFC 8705 §2 client authentication -// (Config.ClientAuthMethod == ClientAuthMethodSelfSignedTLSClientAuth -// or ClientAuthMethodTLSClientAuth): overrides endpoints' Token, -// PushedAuthorizationRequest and Revocation fields, since a -// certificate-authenticated client must present its certificate at PAR -// and token revocation (RevokeToken) too, not just at the token -// endpoint — the same asymmetry a server's own client authentication -// enforces. m may be nil, in which case -// ApplyForClientAuth leaves endpoints untouched and reports false, for -// the same reason ApplyForSenderConstrain does. +// ApplyForClientAuth is ApplyForSenderConstrain, for a client that +// authenticates with its certificate (Config.ClientAuthMethod == +// ClientAuthMethodSelfSignedTLSClientAuth or ClientAuthMethodTLSClientAuth, +// RFC 8705 §2): it overrides endpoints with every alias m advertises, and +// reports false for a nil m. The two are the same because RFC 8705 §5 +// applies to a client doing mutual TLS for either reason; a client doing +// both needs only one call. func (m *MTLSEndpoints) ApplyForClientAuth(endpoints *Endpoints) bool { + return m.apply(endpoints) +} + +// apply overrides each endpoint m advertises an alias for — see +// ApplyForSenderConstrain. +func (m *MTLSEndpoints) apply(endpoints *Endpoints) bool { if m == nil { return false } - if !m.Token.IsZero() { - endpoints.Token = m.Token - } - if !m.PushedAuthorizationRequest.IsZero() { - endpoints.PushedAuthorizationRequest = m.PushedAuthorizationRequest - } - if !m.Revocation.IsZero() { - endpoints.Revocation = m.Revocation + for _, f := range []struct { + alias fapi.URL + target *fapi.URL + }{ + {m.Token, &endpoints.Token}, + {m.PushedAuthorizationRequest, &endpoints.PushedAuthorizationRequest}, + {m.BackchannelAuthentication, &endpoints.BackchannelAuthentication}, + {m.Revocation, &endpoints.Revocation}, + } { + if !f.alias.IsZero() { + *f.target = f.alias + } } return true } diff --git a/client/mtls_endpoints_test.go b/client/mtls_endpoints_test.go index 5fd2c806..d43f5152 100644 --- a/client/mtls_endpoints_test.go +++ b/client/mtls_endpoints_test.go @@ -16,71 +16,45 @@ func mustEndpointURL(t *testing.T, raw string) fapi.URL { return u } -// TestMTLSEndpointsApplyForSenderConstrainOverridesTokenAndBackchannel -// covers ApplyForSenderConstrain's own field selection: Token and -// BackchannelAuthentication move to their mTLS alias; every other -// endpoint (Authorization, PushedAuthorizationRequest) is untouched. -func TestMTLSEndpointsApplyForSenderConstrainOverridesTokenAndBackchannel(t *testing.T) { - aliases := &client.MTLSEndpoints{ - Token: mustEndpointURL(t, "https://mtls.example.com/token"), - BackchannelAuthentication: mustEndpointURL(t, "https://mtls.example.com/backchannel"), - } - endpoints := client.Endpoints{ - Authorization: mustEndpointURL(t, "https://as.example.com/authorize"), - Token: mustEndpointURL(t, "https://as.example.com/token"), - PushedAuthorizationRequest: mustEndpointURL(t, "https://as.example.com/par"), - BackchannelAuthentication: mustEndpointURL(t, "https://as.example.com/backchannel"), - } - - if !aliases.ApplyForSenderConstrain(&endpoints) { - t.Fatal("ApplyForSenderConstrain() = false, want true") - } - if got, want := endpoints.Token.String(), aliases.Token.String(); got != want { - t.Errorf("Token = %s, want alias %s", got, want) - } - if got, want := endpoints.BackchannelAuthentication.String(), aliases.BackchannelAuthentication.String(); got != want { - t.Errorf("BackchannelAuthentication = %s, want alias %s", got, want) - } - if got, want := endpoints.Authorization.String(), "https://as.example.com/authorize"; got != want { - t.Errorf("Authorization = %s, want untouched %s", got, want) - } - if got, want := endpoints.PushedAuthorizationRequest.String(), "https://as.example.com/par"; got != want { - t.Errorf("PushedAuthorizationRequest = %s, want untouched %s", got, want) - } -} - -// TestMTLSEndpointsApplyForClientAuthOverridesTokenAndPAR covers -// ApplyForClientAuth's own field selection: Token, -// PushedAuthorizationRequest and Revocation move to their mTLS alias; -// BackchannelAuthentication is untouched (only ApplyForSenderConstrain -// ever moves it). -func TestMTLSEndpointsApplyForClientAuthOverridesTokenAndPAR(t *testing.T) { - aliases := &client.MTLSEndpoints{ - Token: mustEndpointURL(t, "https://mtls.example.com/token"), - PushedAuthorizationRequest: mustEndpointURL(t, "https://mtls.example.com/par"), - Revocation: mustEndpointURL(t, "https://mtls.example.com/revoke"), - } - endpoints := client.Endpoints{ - Token: mustEndpointURL(t, "https://as.example.com/token"), - PushedAuthorizationRequest: mustEndpointURL(t, "https://as.example.com/par"), - BackchannelAuthentication: mustEndpointURL(t, "https://as.example.com/backchannel"), - Revocation: mustEndpointURL(t, "https://as.example.com/revoke"), - } - - if !aliases.ApplyForClientAuth(&endpoints) { - t.Fatal("ApplyForClientAuth() = false, want true") - } - if got, want := endpoints.Token.String(), aliases.Token.String(); got != want { - t.Errorf("Token = %s, want alias %s", got, want) - } - if got, want := endpoints.PushedAuthorizationRequest.String(), aliases.PushedAuthorizationRequest.String(); got != want { - t.Errorf("PushedAuthorizationRequest = %s, want alias %s", got, want) - } - if got, want := endpoints.Revocation.String(), aliases.Revocation.String(); got != want { - t.Errorf("Revocation = %s, want alias %s", got, want) - } - if got, want := endpoints.BackchannelAuthentication.String(), "https://as.example.com/backchannel"; got != want { - t.Errorf("BackchannelAuthentication = %s, want untouched %s", got, want) +// TestMTLSEndpointsApplyEveryAdvertisedAlias covers RFC 8705 §5: a client +// doing mutual TLS, for client authentication or for certificate-bound +// tokens alike, uses every alias the server advertises for an endpoint it +// calls directly. Authorization, which the user agent calls, is never +// aliased. +func TestMTLSEndpointsApplyEveryAdvertisedAlias(t *testing.T) { + for name, apply := range map[string]func(*client.MTLSEndpoints, *client.Endpoints) bool{ + "ApplyForSenderConstrain": (*client.MTLSEndpoints).ApplyForSenderConstrain, + "ApplyForClientAuth": (*client.MTLSEndpoints).ApplyForClientAuth, + } { + t.Run(name, func(t *testing.T) { + aliases := &client.MTLSEndpoints{ + Token: mustEndpointURL(t, "https://mtls.example.com/token"), + PushedAuthorizationRequest: mustEndpointURL(t, "https://mtls.example.com/par"), + BackchannelAuthentication: mustEndpointURL(t, "https://mtls.example.com/backchannel"), + Revocation: mustEndpointURL(t, "https://mtls.example.com/revoke"), + } + endpoints := client.Endpoints{ + Authorization: mustEndpointURL(t, "https://as.example.com/authorize"), + Token: mustEndpointURL(t, "https://as.example.com/token"), + PushedAuthorizationRequest: mustEndpointURL(t, "https://as.example.com/par"), + BackchannelAuthentication: mustEndpointURL(t, "https://as.example.com/backchannel"), + Revocation: mustEndpointURL(t, "https://as.example.com/revoke"), + } + if !apply(aliases, &endpoints) { + t.Fatalf("%s() = false, want true", name) + } + for field, got := range map[string][2]string{ + "Token": {endpoints.Token.String(), aliases.Token.String()}, + "PushedAuthorizationRequest": {endpoints.PushedAuthorizationRequest.String(), aliases.PushedAuthorizationRequest.String()}, + "BackchannelAuthentication": {endpoints.BackchannelAuthentication.String(), aliases.BackchannelAuthentication.String()}, + "Revocation": {endpoints.Revocation.String(), aliases.Revocation.String()}, + "Authorization": {endpoints.Authorization.String(), "https://as.example.com/authorize"}, + } { + if got[0] != got[1] { + t.Errorf("%s = %s, want %s", field, got[0], got[1]) + } + } + }) } } diff --git a/cmd/conformance-client/main.go b/cmd/conformance-client/main.go index e4406bb8..2c75c421 100644 --- a/cmd/conformance-client/main.go +++ b/cmd/conformance-client/main.go @@ -578,10 +578,9 @@ func buildModuleClient(ctx context.Context, d moduleDriver, module suiteModule) } if d.ClientAuthMTLS { cfg.ClientAuthMethod = storage.ClientAuthMethodSelfSignedTLSClientAuth - // Covers Token+PAR — a superset of what sender-constrain-only - // needs (Token only; RFC 8705 §3 has no PAR-time - // pre-commitment concept), so the MTLS+MTLS combo is correctly - // handled by this branch alone. + // Applies every advertised alias (RFC 8705 §5), as + // ApplyForSenderConstrain does, so the MTLS+MTLS combination + // needs only this call. // Without mtls_endpoint_aliases (optional, RFC 8705 §5) the // ordinary endpoints take mutual TLS — the suite's // *-happy-path-no-mtls-endpoint-aliases modules check exactly diff --git a/docs/guides/mtls.md b/docs/guides/mtls.md index dc868a6c..2558694e 100644 --- a/docs/guides/mtls.md +++ b/docs/guides/mtls.md @@ -25,9 +25,10 @@ the `*http.Client` you pass as `Dependencies.HTTP`: ```go endpoints := discovered.Endpoints -// RFC 8705 §5: send certificate-carrying requests to the mTLS aliases. -discovered.MTLSEndpointAliases.ApplyForClientAuth(&endpoints) // PAR and token -discovered.MTLSEndpointAliases.ApplyForSenderConstrain(&endpoints) // token, CIBA, revocation +// RFC 8705 §5: a client doing mutual TLS sends every request it makes +// directly (PAR, token, CIBA, revocation) to the mTLS aliases, when the +// server advertises them. ApplyForSenderConstrain does the same. +discovered.MTLSEndpointAliases.ApplyForClientAuth(&endpoints) c, err := client.NewFromDiscovery(discovered, client.Config{ // ClientID, Profile, Limits ...