Skip to content
Open
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
24 changes: 24 additions & 0 deletions auth/authorization_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ type AuthorizationResult struct {
Code string
// State string returned by the authorization server.
State string
// Iss is the issuer identifier returned by the authorization server in the
// authorization response per [RFC 9207]. The AuthorizationCodeFetcher should
// populate this from the "iss" query parameter in the redirect URI if present.
//
// [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207
Iss string
}

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

return h.exchangeAuthorizationCode(ctx, cfg, authRes, prm.Resource)
}
Expand Down Expand Up @@ -551,6 +560,21 @@ func (h *AuthorizationCodeHandler) getAuthorizationCode(ctx context.Context, cfg
}, nil
}

// validateIssuerResponse validates the "iss" parameter in an authorization response
// per [RFC 9207].
//
// [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207
func validateIssuerResponse(iss, expectedIssuer string, issParameterSupported bool) error {
if iss != "" {
if iss != expectedIssuer {
return fmt.Errorf("authorization response issuer %q does not match expected issuer %q", iss, expectedIssuer)
}
} else if issParameterSupported {
return fmt.Errorf("authorization server advertises RFC 9207 iss parameter support but none was received in the authorization response")
}
return nil
}

// exchangeAuthorizationCode exchanges the authorization code for a token
// and stores it in a token source.
func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context, cfg *oauth2.Config, authResult *authResult, resourceURL string) error {
Expand Down
54 changes: 54 additions & 0 deletions auth/authorization_code_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ func TestAuthorize(t *testing.T) {
return &AuthorizationResult{
Code: location.Query().Get("code"),
State: location.Query().Get("state"),
Iss: location.Query().Get("iss"),
}, nil
},
})
Expand Down Expand Up @@ -696,6 +697,59 @@ func TestDynamicRegistration(t *testing.T) {
}
}

func TestValidateIssuerResponse(t *testing.T) {
const expectedIssuer = "https://auth.example.com"

tests := []struct {
name string
iss string
issSupported bool
wantErr bool
wantErrContains string
}{
{
name: "ValidIss",
iss: expectedIssuer,
issSupported: true,
},
{
name: "WrongIss",
iss: "https://attacker.example.com",
issSupported: true,
wantErr: true,
wantErrContains: "does not match expected issuer",
},
{
name: "MissingIssWhenRequired",
iss: "",
issSupported: true,
wantErr: true,
wantErrContains: "RFC 9207",
},
{
name: "MissingIssWhenNotRequired",
iss: "",
issSupported: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateIssuerResponse(tt.iss, expectedIssuer, tt.issSupported)
if tt.wantErr {
if err == nil {
t.Fatalf("validateIssuerResponse() = nil, want error containing %q", tt.wantErrContains)
}
if !strings.Contains(err.Error(), tt.wantErrContains) {
t.Errorf("validateIssuerResponse() error = %q, want it to contain %q", err.Error(), tt.wantErrContains)
}
} else if err != nil {
t.Fatalf("validateIssuerResponse() unexpected error = %v", err)
}
})
}
}

// validConfig for test to create an AuthorizationCodeHandler using its constructor.
// Values that are relevant to the test should be set explicitly.
func validConfig() *AuthorizationCodeHandlerConfig {
Expand Down
1 change: 1 addition & 0 deletions conformance/everything-client/client_private.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ func fetchAuthorizationCodeAndState(ctx context.Context, args *auth.Authorizatio
return &auth.AuthorizationResult{
Code: locURL.Query().Get("code"),
State: locURL.Query().Get("state"),
Iss: locURL.Query().Get("iss"),
}, nil
}

Expand Down
23 changes: 21 additions & 2 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
1. [Token Passthrough](#token-passthrough)
1. [Server-Side Request Forgery (SSRF)](#server-side-request-forgery-(ssrf))
1. [Session Hijacking](#session-hijacking)
1. [Issuer Mix-Up](#issuer-mix-up)
1. [Utilities](#utilities)
1. [Cancellation](#cancellation)
1. [Ping](#ping)
Expand Down Expand Up @@ -327,6 +328,7 @@ This handler supports:
- [Client ID Metadata Documents](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#client-id-metadata-documents)
- [Pre-registered clients](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration)
- [Dynamic Client Registration](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#dynamic-client-registration)
- [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207) Authorization Server Issuer Identification

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

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

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

### Issuer Mix-Up

The [mitigation](https://www.rfc-editor.org/rfc/rfc9207) against issuer mix-up attacks is
implemented per [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207). The SDK client validates
the `iss` parameter in authorization responses to ensure they originated from the expected
authorization server:

- If `iss` is present in the redirect URI, the SDK verifies it matches the issuer from the
authorization server's metadata. A mismatch results in an error.
- If `iss` is absent but the authorization server advertises
`authorization_response_iss_parameter_supported: true` in its [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)
metadata, the SDK rejects the response with an error.

The `AuthorizationCodeFetcher` is responsible for extracting the `iss` query parameter from
the redirect URI and returning it in [`AuthorizationResult.Iss`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#AuthorizationResult).

## Utilities

### Cancellation
Expand Down
1 change: 1 addition & 0 deletions examples/auth/client/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func (r *codeReceiver) serveRedirectHandler(listener net.Listener) {
r.authChan <- &auth.AuthorizationResult{
Code: req.URL.Query().Get("code"),
State: req.URL.Query().Get("state"),
Iss: req.URL.Query().Get("iss"),
}
fmt.Fprint(w, "Authentication successful. You can close this window.")
})
Expand Down
6 changes: 3 additions & 3 deletions examples/server/auth-middleware/go.mod
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
module auth-middleware-example

go 1.23.0
go 1.25.0

require (
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/modelcontextprotocol/go-sdk v0.3.0
)

require (
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
)

replace github.com/modelcontextprotocol/go-sdk => ../../../
2 changes: 2 additions & 0 deletions examples/server/auth-middleware/go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
Expand All @@ -9,5 +10,6 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
2 changes: 1 addition & 1 deletion examples/server/rate-limiting/go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/modelcontextprotocol/go-sdk/examples/rate-limiting

go 1.23.0
go 1.25.0

require (
github.com/modelcontextprotocol/go-sdk v0.3.0
Expand Down
22 changes: 20 additions & 2 deletions internal/docs/protocol.src.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ This handler supports:
- [Client ID Metadata Documents](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#client-id-metadata-documents)
- [Pre-registered clients](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration)
- [Dynamic Client Registration](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#dynamic-client-registration)
- [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207) Authorization Server Issuer Identification

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

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

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

### Issuer Mix-Up

The [mitigation](https://www.rfc-editor.org/rfc/rfc9207) against issuer mix-up attacks is
implemented per [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207). The SDK client validates
the `iss` parameter in authorization responses to ensure they originated from the expected
authorization server:

- If `iss` is present in the redirect URI, the SDK verifies it matches the issuer from the
authorization server's metadata. A mismatch results in an error.
- If `iss` is absent but the authorization server advertises
`authorization_response_iss_parameter_supported: true` in its [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)
metadata, the SDK rejects the response with an error.

The `AuthorizationCodeFetcher` is responsible for extracting the `iss` query parameter from
the redirect URI and returning it in [`AuthorizationResult.Iss`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#AuthorizationResult).

## Utilities

### Cancellation
Expand Down
6 changes: 5 additions & 1 deletion internal/oauthtest/fake_authorization_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"maps"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"testing"

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

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

redirectURL := fmt.Sprintf("%s?code=%s&state=%s", redirectURI, code, state)
redirectURL := fmt.Sprintf("%s?code=%s&state=%s&iss=%s", redirectURI, code, state, url.QueryEscape(issuer))
http.Redirect(w, r, redirectURL, http.StatusFound)
}

Expand Down
7 changes: 7 additions & 0 deletions oauthex/auth_meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,13 @@ type AuthServerMeta struct {
// ClientIDMetadataDocumentSupported is a boolean indicating whether the authorization server
// supports client ID metadata documents.
ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported,omitempty"`

// AuthorizationResponseIssParameterSupported indicates whether the authorization server
// provides the "iss" parameter in authorization responses per [RFC 9207].
// When true, clients must verify the "iss" parameter is present and matches the Issuer field.
//
// [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207
AuthorizationResponseIssParameterSupported bool `json:"authorization_response_iss_parameter_supported,omitempty"`
}

// GetAuthServerMeta issues a GET request to retrieve authorization server metadata
Expand Down
Loading