Skip to content

Commit cea90b1

Browse files
committed
auth: Reference implementation of SEP-2468 / RFC9207
1 parent 755b9ed commit cea90b1

11 files changed

Lines changed: 139 additions & 9 deletions

File tree

auth/authorization_code.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ type AuthorizationResult struct {
6565
Code string
6666
// State string returned by the authorization server.
6767
State string
68+
// Iss is the issuer identifier returned by the authorization server in the
69+
// authorization response per [RFC 9207]. The AuthorizationCodeFetcher should
70+
// populate this from the "iss" query parameter in the redirect URI if present.
71+
//
72+
// [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207
73+
Iss string
6874
}
6975

7076
// AuthorizationArgs is the input to [AuthorizationCodeHandlerConfig].AuthorizationCodeFetcher.
@@ -251,6 +257,9 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
251257
// Purposefully leaving the error unwrappable so it can be handled by the caller.
252258
return err
253259
}
260+
if err := validateIssuerResponse(authRes.Iss, asm.Issuer, asm.AuthorizationResponseIssParameterSupported); err != nil {
261+
return err
262+
}
254263

255264
return h.exchangeAuthorizationCode(ctx, cfg, authRes, prm.Resource)
256265
}
@@ -551,6 +560,21 @@ func (h *AuthorizationCodeHandler) getAuthorizationCode(ctx context.Context, cfg
551560
}, nil
552561
}
553562

563+
// validateIssuerResponse validates the "iss" parameter in an authorization response
564+
// per [RFC 9207].
565+
//
566+
// [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207
567+
func validateIssuerResponse(iss, expectedIssuer string, issParameterSupported bool) error {
568+
if iss != "" {
569+
if iss != expectedIssuer {
570+
return fmt.Errorf("authorization response issuer %q does not match expected issuer %q", iss, expectedIssuer)
571+
}
572+
} else if issParameterSupported {
573+
return fmt.Errorf("authorization server advertises RFC 9207 iss parameter support but none was received in the authorization response")
574+
}
575+
return nil
576+
}
577+
554578
// exchangeAuthorizationCode exchanges the authorization code for a token
555579
// and stores it in a token source.
556580
func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context, cfg *oauth2.Config, authResult *authResult, resourceURL string) error {

auth/authorization_code_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ func TestAuthorize(t *testing.T) {
7777
return &AuthorizationResult{
7878
Code: location.Query().Get("code"),
7979
State: location.Query().Get("state"),
80+
Iss: location.Query().Get("iss"),
8081
}, nil
8182
},
8283
})
@@ -696,6 +697,59 @@ func TestDynamicRegistration(t *testing.T) {
696697
}
697698
}
698699

700+
func TestValidateIssuerResponse(t *testing.T) {
701+
const expectedIssuer = "https://auth.example.com"
702+
703+
tests := []struct {
704+
name string
705+
iss string
706+
issSupported bool
707+
wantErr bool
708+
wantErrContains string
709+
}{
710+
{
711+
name: "ValidIss",
712+
iss: expectedIssuer,
713+
issSupported: true,
714+
},
715+
{
716+
name: "WrongIss",
717+
iss: "https://attacker.example.com",
718+
issSupported: true,
719+
wantErr: true,
720+
wantErrContains: "does not match expected issuer",
721+
},
722+
{
723+
name: "MissingIssWhenRequired",
724+
iss: "",
725+
issSupported: true,
726+
wantErr: true,
727+
wantErrContains: "RFC 9207",
728+
},
729+
{
730+
name: "MissingIssWhenNotRequired",
731+
iss: "",
732+
issSupported: false,
733+
},
734+
}
735+
736+
for _, tt := range tests {
737+
t.Run(tt.name, func(t *testing.T) {
738+
err := validateIssuerResponse(tt.iss, expectedIssuer, tt.issSupported)
739+
if tt.wantErr {
740+
if err == nil {
741+
t.Fatalf("validateIssuerResponse() = nil, want error containing %q", tt.wantErrContains)
742+
}
743+
if !strings.Contains(err.Error(), tt.wantErrContains) {
744+
t.Errorf("validateIssuerResponse() error = %q, want it to contain %q", err.Error(), tt.wantErrContains)
745+
}
746+
} else if err != nil {
747+
t.Fatalf("validateIssuerResponse() unexpected error = %v", err)
748+
}
749+
})
750+
}
751+
}
752+
699753
// validConfig for test to create an AuthorizationCodeHandler using its constructor.
700754
// Values that are relevant to the test should be set explicitly.
701755
func validConfig() *AuthorizationCodeHandlerConfig {

conformance/everything-client/client_private.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ func fetchAuthorizationCodeAndState(ctx context.Context, args *auth.Authorizatio
7777
return &auth.AuthorizationResult{
7878
Code: locURL.Query().Get("code"),
7979
State: locURL.Query().Get("state"),
80+
Iss: locURL.Query().Get("iss"),
8081
}, nil
8182
}
8283

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 (SSRF)](#server-side-request-forgery-(ssrf))
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)
@@ -327,6 +328,7 @@ This handler supports:
327328
- [Client ID Metadata Documents](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#client-id-metadata-documents)
328329
- [Pre-registered clients](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration)
329330
- [Dynamic Client Registration](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#dynamic-client-registration)
331+
- [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207) Authorization Server Issuer Identification
330332

331333
To use it, configure the handler and assign it to the transport:
332334

@@ -338,11 +340,12 @@ authHandler, _ := auth.NewAuthorizationCodeHandler(&auth.AuthorizationCodeHandle
338340
// PreregisteredClientConfig: ...
339341
// DynamicClientRegistrationConfig: ...
340342
AuthorizationCodeFetcher: func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
341-
// Open the args.URL in a browser and return the resulting code and state.
343+
// Open the args.URL in a browser and return the resulting code, state, and iss.
342344
// See full example in examples/auth/client/main.go.
343345
code := ...
344346
state := ...
345-
return &auth.AuthorizationResult{Code: code, State: state}, nil
347+
iss := ... // "iss" query parameter from the redirect URI (RFC 9207)
348+
return &auth.AuthorizationResult{Code: code, State: state, Iss: iss}, nil
346349
},
347350
})
348351

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

432+
### Issuer Mix-Up
433+
434+
The [mitigation](https://www.rfc-editor.org/rfc/rfc9207) against issuer mix-up attacks is
435+
implemented per [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207). The SDK client validates
436+
the `iss` parameter in authorization responses to ensure they originated from the expected
437+
authorization server:
438+
439+
- If `iss` is present in the redirect URI, the SDK verifies it matches the issuer from the
440+
authorization server's metadata. A mismatch results in an error.
441+
- If `iss` is absent but the authorization server advertises
442+
`authorization_response_iss_parameter_supported: true` in its [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)
443+
metadata, the SDK rejects the response with an error.
444+
445+
The `AuthorizationCodeFetcher` is responsible for extracting the `iss` query parameter from
446+
the redirect URI and returning it in [`AuthorizationResult.Iss`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#AuthorizationResult).
447+
429448
## Utilities
430449

431450
### Cancellation

examples/auth/client/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ func (r *codeReceiver) serveRedirectHandler(listener net.Listener) {
3939
r.authChan <- &auth.AuthorizationResult{
4040
Code: req.URL.Query().Get("code"),
4141
State: req.URL.Query().Get("state"),
42+
Iss: req.URL.Query().Get("iss"),
4243
}
4344
fmt.Fprint(w, "Authentication successful. You can close this window.")
4445
})
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
@@ -252,6 +252,7 @@ This handler supports:
252252
- [Client ID Metadata Documents](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#client-id-metadata-documents)
253253
- [Pre-registered clients](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration)
254254
- [Dynamic Client Registration](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#dynamic-client-registration)
255+
- [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207) Authorization Server Issuer Identification
255256

256257
To use it, configure the handler and assign it to the transport:
257258

@@ -263,11 +264,12 @@ authHandler, _ := auth.NewAuthorizationCodeHandler(&auth.AuthorizationCodeHandle
263264
// PreregisteredClientConfig: ...
264265
// DynamicClientRegistrationConfig: ...
265266
AuthorizationCodeFetcher: func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
266-
// Open the args.URL in a browser and return the resulting code and state.
267+
// Open the args.URL in a browser and return the resulting code, state, and iss.
267268
// See full example in examples/auth/client/main.go.
268269
code := ...
269270
state := ...
270-
return &auth.AuthorizationResult{Code: code, State: state}, nil
271+
iss := ... // "iss" query parameter from the redirect URI (RFC 9207)
272+
return &auth.AuthorizationResult{Code: code, State: state, Iss: iss}, nil
271273
},
272274
})
273275

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

356+
### Issuer Mix-Up
357+
358+
The [mitigation](https://www.rfc-editor.org/rfc/rfc9207) against issuer mix-up attacks is
359+
implemented per [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207). The SDK client validates
360+
the `iss` parameter in authorization responses to ensure they originated from the expected
361+
authorization server:
362+
363+
- If `iss` is present in the redirect URI, the SDK verifies it matches the issuer from the
364+
authorization server's metadata. A mismatch results in an error.
365+
- If `iss` is absent but the authorization server advertises
366+
`authorization_response_iss_parameter_supported: true` in its [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)
367+
metadata, the SDK rejects the response with an error.
368+
369+
The `AuthorizationCodeFetcher` is responsible for extracting the `iss` query parameter from
370+
the redirect URI and returning it in [`AuthorizationResult.Iss`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#AuthorizationResult).
371+
354372
## Utilities
355373

356374
### Cancellation

internal/oauthtest/fake_authorization_server.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"maps"
1818
"net/http"
1919
"net/http/httptest"
20+
"net/url"
2021
"slices"
2122
"testing"
2223

@@ -149,6 +150,8 @@ func (s *FakeAuthorizationServer) handleMetadata(w http.ResponseWriter, r *http.
149150
CodeChallengeMethodsSupported: []string{"S256"},
150151
ClientIDMetadataDocumentSupported: cimdSupported,
151152
TokenEndpointAuthMethodsSupported: []string{"client_secret_post", "client_secret_basic"},
153+
// Advertise RFC 9207 support: the authorize endpoint includes "iss" in responses.
154+
AuthorizationResponseIssParameterSupported: true,
152155
}
153156
// Set CORS headers for cross-origin client discovery.
154157
w.Header().Set("Access-Control-Allow-Origin", "*")
@@ -237,8 +240,9 @@ func (s *FakeAuthorizationServer) handleAuthorize(w http.ResponseWriter, r *http
237240
}
238241

239242
state := r.URL.Query().Get("state")
243+
issuer := s.URL() + s.config.IssuerPath
240244

241-
redirectURL := fmt.Sprintf("%s?code=%s&state=%s", redirectURI, code, state)
245+
redirectURL := fmt.Sprintf("%s?code=%s&state=%s&iss=%s", redirectURI, code, state, url.QueryEscape(issuer))
242246
http.Redirect(w, r, redirectURL, http.StatusFound)
243247
}
244248

0 commit comments

Comments
 (0)