Skip to content

Commit 2d47cc9

Browse files
authored
auth: issuer mix-up mitigation (#859)
This PR functions as a Reference implementation of [SEP-2468](modelcontextprotocol/modelcontextprotocol#2468) / [RFC9207](https://datatracker.ietf.org/doc/rfc9207/). This PR hardens the MCP OAuth Client functionality against Mix-Up attacks: > Mix-up attacks aim to steal an authorization code or access token by > tricking the client into sending the authorization code or access > token to the attacker instead of the honest authorization or resource > server This PR hardens the client by adding support for a new `iss` parameter in authorization responses: - Authorization Servers broadcast support for the `iss` parameter via the `authorization_response_iss_parameter_supported` metadata parameter - If the parameter is supported, clients expect to receive the `iss` parameter in the authorization response - Clients compare the `iss` parameter in the authorization response to the `Issuer` parameter in the authorization metadata. The two must match exactly for the response to be processed. Fixes #941
1 parent 4cbdd6a commit 2d47cc9

11 files changed

Lines changed: 151 additions & 9 deletions

File tree

auth/authorization_code.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ type AuthorizationResult struct {
5050
Code string
5151
// State string returned by the authorization server.
5252
State string
53+
// Iss is the issuer identifier returned by the authorization server in the
54+
// authorization response per [RFC 9207]. The AuthorizationCodeFetcher should
55+
// populate this from the "iss" query parameter in the redirect URI if present.
56+
//
57+
// [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207
58+
Iss string
5359
}
5460

5561
// AuthorizationArgs is the input to [AuthorizationCodeFetcher].
@@ -318,6 +324,9 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
318324
// Purposefully leaving the error unwrappable so it can be handled by the caller.
319325
return err
320326
}
327+
if err := validateIssuerResponse(authRes.Iss, asm.Issuer, asm.AuthorizationResponseIssParameterSupported); err != nil {
328+
return err
329+
}
321330

322331
err = h.exchangeAuthorizationCode(ctx, cfg, authRes, prm.Resource)
323332
if err != nil {
@@ -560,6 +569,27 @@ func (h *AuthorizationCodeHandler) getAuthorizationCode(ctx context.Context, cfg
560569
}, nil
561570
}
562571

572+
// validateIssuerResponse validates the "iss" parameter in an authorization response
573+
// per [RFC 9207].
574+
//
575+
// [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207
576+
func validateIssuerResponse(iss, expectedIssuer string, issParameterSupported bool) error {
577+
if issParameterSupported {
578+
if iss == "" {
579+
return fmt.Errorf("authorization server advertises RFC 9207 iss parameter support but none was received in the authorization response")
580+
}
581+
if iss != expectedIssuer {
582+
return fmt.Errorf("authorization response issuer %q does not match expected issuer %q", iss, expectedIssuer)
583+
}
584+
} else {
585+
if iss != "" {
586+
return fmt.Errorf("authorization server does not advertise RFC 9207 iss parameter support but iss was received in the authorization response")
587+
}
588+
}
589+
590+
return nil
591+
}
592+
563593
// exchangeAuthorizationCode exchanges the authorization code for a token
564594
// and stores it in a token source.
565595
func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context, cfg *oauth2.Config, authResult *authResult, resourceURL string) error {

auth/authorization_code_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ func TestAuthorize(t *testing.T) {
7878
return &AuthorizationResult{
7979
Code: location.Query().Get("code"),
8080
State: location.Query().Get("state"),
81+
Iss: location.Query().Get("iss"),
8182
}, nil
8283
},
8384
})
@@ -176,6 +177,7 @@ func TestAuthorize_ScopeAccumulation(t *testing.T) {
176177
return &AuthorizationResult{
177178
Code: loc.Query().Get("code"),
178179
State: loc.Query().Get("state"),
180+
Iss: loc.Query().Get("iss"),
179181
}, nil
180182
},
181183
})
@@ -736,6 +738,59 @@ func TestDynamicRegistration(t *testing.T) {
736738
}
737739
}
738740

741+
func TestValidateIssuerResponse(t *testing.T) {
742+
const expectedIssuer = "https://auth.example.com"
743+
744+
tests := []struct {
745+
name string
746+
iss string
747+
issSupported bool
748+
wantErr bool
749+
wantErrContains string
750+
}{
751+
{
752+
name: "ValidIss",
753+
iss: expectedIssuer,
754+
issSupported: true,
755+
},
756+
{
757+
name: "WrongIss",
758+
iss: "https://attacker.example.com",
759+
issSupported: true,
760+
wantErr: true,
761+
wantErrContains: "does not match expected issuer",
762+
},
763+
{
764+
name: "MissingIssWhenRequired",
765+
iss: "",
766+
issSupported: true,
767+
wantErr: true,
768+
wantErrContains: "RFC 9207",
769+
},
770+
{
771+
name: "MissingIssWhenNotRequired",
772+
iss: "",
773+
issSupported: false,
774+
},
775+
}
776+
777+
for _, tt := range tests {
778+
t.Run(tt.name, func(t *testing.T) {
779+
err := validateIssuerResponse(tt.iss, expectedIssuer, tt.issSupported)
780+
if tt.wantErr {
781+
if err == nil {
782+
t.Fatalf("validateIssuerResponse() = nil, want error containing %q", tt.wantErrContains)
783+
}
784+
if !strings.Contains(err.Error(), tt.wantErrContains) {
785+
t.Errorf("validateIssuerResponse() error = %q, want it to contain %q", err.Error(), tt.wantErrContains)
786+
}
787+
} else if err != nil {
788+
t.Fatalf("validateIssuerResponse() unexpected error = %v", err)
789+
}
790+
})
791+
}
792+
}
793+
739794
func TestInferApplicationType(t *testing.T) {
740795
tests := []struct {
741796
name string

conformance/everything-client/main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ func init() {
6666
"auth/token-endpoint-auth-basic",
6767
"auth/token-endpoint-auth-post",
6868
"auth/token-endpoint-auth-none",
69+
"auth/iss-supported",
70+
"auth/iss-not-advertised",
71+
"auth/iss-supported-missing",
72+
"auth/iss-wrong-issuer",
73+
"auth/iss-unexpected",
6974
}
7075
for _, scenario := range authScenarios {
7176
registerScenario(scenario, runAuthClient)
@@ -232,6 +237,7 @@ func fetchAuthorizationCodeAndState(ctx context.Context, args *auth.Authorizatio
232237
return &auth.AuthorizationResult{
233238
Code: locURL.Query().Get("code"),
234239
State: locURL.Query().Get("state"),
240+
Iss: locURL.Query().Get("iss"),
235241
}, nil
236242
}
237243

docs/protocol.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
1. [Token Passthrough](#token-passthrough)
1616
1. [Server-Side Request Forgery](#server-side-request-forgery)
1717
1. [Session Hijacking](#session-hijacking)
18+
1. [Issuer Mix-Up](#issuer-mix-up)
1819
1. [Utilities](#utilities)
1920
1. [Cancellation](#cancellation)
2021
1. [Ping](#ping)
@@ -322,6 +323,7 @@ This handler supports:
322323
- [Client ID Metadata Documents](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#client-id-metadata-documents)
323324
- [Pre-registered clients](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration)
324325
- [Dynamic Client Registration](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#dynamic-client-registration)
326+
- [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207) Authorization Server Issuer Identification
325327

326328
To use it, configure the handler and assign it to the transport:
327329

@@ -333,11 +335,12 @@ authHandler, _ := auth.NewAuthorizationCodeHandler(&auth.AuthorizationCodeHandle
333335
// PreregisteredClientConfig: ...
334336
// DynamicClientRegistrationConfig: ...
335337
AuthorizationCodeFetcher: func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
336-
// Open the args.URL in a browser and return the resulting code and state.
338+
// Open the args.URL in a browser and return the resulting code, state, and iss.
337339
// See full example in examples/auth/client/main.go.
338340
code := ...
339341
state := ...
340-
return &auth.AuthorizationResult{Code: code, State: state}, nil
342+
iss := ... // "iss" query parameter from the redirect URI (RFC 9207)
343+
return &auth.AuthorizationResult{Code: code, State: state, Iss: iss}, nil
341344
},
342345
})
343346

@@ -490,6 +493,22 @@ sets `UserID` on the returned `TokenInfo`, the streamable transport will:
490493
`TokenInfo.UserID` to enable this protection. This prevents an attacker with a valid
491494
token from hijacking another user's session by guessing or obtaining their session ID.
492495

496+
### Issuer Mix-Up
497+
498+
The [mitigation](https://www.rfc-editor.org/rfc/rfc9207) against issuer mix-up attacks is
499+
implemented per [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207). The SDK client validates
500+
the `iss` parameter in authorization responses to ensure they originated from the expected
501+
authorization server:
502+
503+
- If `iss` is present in the redirect URI, the SDK verifies it matches the issuer from the
504+
authorization server's metadata. A mismatch results in an error.
505+
- If `iss` is absent but the authorization server advertises
506+
`authorization_response_iss_parameter_supported: true` in its [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)
507+
metadata, the SDK rejects the response with an error.
508+
509+
The `AuthorizationCodeFetcher` is responsible for extracting the `iss` query parameter from
510+
the redirect URI and returning it in [`AuthorizationResult.Iss`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#AuthorizationResult).
511+
493512
## Utilities
494513

495514
### Cancellation

examples/auth/client/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ func (r *codeReceiver) serveRedirectHandler(listener net.Listener) {
3636
r.authChan <- &auth.AuthorizationResult{
3737
Code: req.URL.Query().Get("code"),
3838
State: req.URL.Query().Get("state"),
39+
Iss: req.URL.Query().Get("iss"),
3940
}
4041
fmt.Fprint(w, "Authentication successful. You can close this window.")
4142
})
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
module auth-middleware-example
22

3-
go 1.23.0
3+
go 1.25.0
44

55
require (
6-
github.com/golang-jwt/jwt/v5 v5.2.2
6+
github.com/golang-jwt/jwt/v5 v5.3.1
77
github.com/modelcontextprotocol/go-sdk v0.3.0
88
)
99

1010
require (
1111
github.com/google/jsonschema-go v0.4.2 // indirect
1212
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
13-
golang.org/x/oauth2 v0.30.0 // indirect
13+
golang.org/x/oauth2 v0.35.0 // indirect
1414
)
1515

1616
replace github.com/modelcontextprotocol/go-sdk => ../../../

examples/server/auth-middleware/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
22
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
3+
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
34
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
45
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
56
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
@@ -9,5 +10,6 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI
910
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
1011
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
1112
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
13+
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
1214
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
1315
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=

examples/server/rate-limiting/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/modelcontextprotocol/go-sdk/examples/rate-limiting
22

3-
go 1.23.0
3+
go 1.25.0
44

55
require (
66
github.com/modelcontextprotocol/go-sdk v0.3.0

internal/docs/protocol.src.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ This handler supports:
247247
- [Client ID Metadata Documents](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#client-id-metadata-documents)
248248
- [Pre-registered clients](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration)
249249
- [Dynamic Client Registration](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#dynamic-client-registration)
250+
- [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207) Authorization Server Issuer Identification
250251

251252
To use it, configure the handler and assign it to the transport:
252253

@@ -258,11 +259,12 @@ authHandler, _ := auth.NewAuthorizationCodeHandler(&auth.AuthorizationCodeHandle
258259
// PreregisteredClientConfig: ...
259260
// DynamicClientRegistrationConfig: ...
260261
AuthorizationCodeFetcher: func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
261-
// Open the args.URL in a browser and return the resulting code and state.
262+
// Open the args.URL in a browser and return the resulting code, state, and iss.
262263
// See full example in examples/auth/client/main.go.
263264
code := ...
264265
state := ...
265-
return &auth.AuthorizationResult{Code: code, State: state}, nil
266+
iss := ... // "iss" query parameter from the redirect URI (RFC 9207)
267+
return &auth.AuthorizationResult{Code: code, State: state, Iss: iss}, nil
266268
},
267269
})
268270

@@ -415,6 +417,22 @@ sets `UserID` on the returned `TokenInfo`, the streamable transport will:
415417
`TokenInfo.UserID` to enable this protection. This prevents an attacker with a valid
416418
token from hijacking another user's session by guessing or obtaining their session ID.
417419

420+
### Issuer Mix-Up
421+
422+
The [mitigation](https://www.rfc-editor.org/rfc/rfc9207) against issuer mix-up attacks is
423+
implemented per [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207). The SDK client validates
424+
the `iss` parameter in authorization responses to ensure they originated from the expected
425+
authorization server:
426+
427+
- If `iss` is present in the redirect URI, the SDK verifies it matches the issuer from the
428+
authorization server's metadata. A mismatch results in an error.
429+
- If `iss` is absent but the authorization server advertises
430+
`authorization_response_iss_parameter_supported: true` in its [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)
431+
metadata, the SDK rejects the response with an error.
432+
433+
The `AuthorizationCodeFetcher` is responsible for extracting the `iss` query parameter from
434+
the redirect URI and returning it in [`AuthorizationResult.Iss`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#AuthorizationResult).
435+
418436
## Utilities
419437

420438
### Cancellation

internal/oauthtest/fake_authorization_server.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"maps"
1616
"net/http"
1717
"net/http/httptest"
18+
"net/url"
1819
"slices"
1920
"testing"
2021

@@ -178,6 +179,8 @@ func (s *FakeAuthorizationServer) handleMetadata(w http.ResponseWriter, r *http.
178179
CodeChallengeMethodsSupported: []string{"S256"},
179180
ClientIDMetadataDocumentSupported: cimdSupported,
180181
TokenEndpointAuthMethodsSupported: []string{"client_secret_post", "client_secret_basic"},
182+
// Advertise RFC 9207 support: the authorize endpoint includes "iss" in responses.
183+
AuthorizationResponseIssParameterSupported: true,
181184
}
182185
// Set CORS headers for cross-origin client discovery.
183186
w.Header().Set("Access-Control-Allow-Origin", "*")
@@ -267,8 +270,9 @@ func (s *FakeAuthorizationServer) handleAuthorize(w http.ResponseWriter, r *http
267270
}
268271

269272
state := r.URL.Query().Get("state")
273+
issuer := s.URL() + s.config.IssuerPath
270274

271-
redirectURL := fmt.Sprintf("%s?code=%s&state=%s", redirectURI, code, state)
275+
redirectURL := fmt.Sprintf("%s?code=%s&state=%s&iss=%s", redirectURI, code, state, url.QueryEscape(issuer))
272276
http.Redirect(w, r, redirectURL, http.StatusFound)
273277
}
274278

0 commit comments

Comments
 (0)