mcp connection
This commit is contained in:
186
go-api/internal/oauth/metadata.go
Normal file
186
go-api/internal/oauth/metadata.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Discovery: the two documents an MCP client reads before it can authenticate.
|
||||
//
|
||||
// The MCP authorization flow starts with the client calling the MCP endpoint
|
||||
// with no token, getting a 401, and following its way to an authorization
|
||||
// server. Two RFCs define the path:
|
||||
//
|
||||
// RFC 9728 Protected Resource Metadata — served BY THE RESOURCE (the MCP
|
||||
// server). Answers "which authorization server issues tokens for
|
||||
// you". The 401's WWW-Authenticate header points here.
|
||||
// RFC 8414 Authorization Server Metadata — served by the AS. Answers "where
|
||||
// are your authorize, token and registration endpoints, and what do
|
||||
// you support".
|
||||
//
|
||||
// Both are unauthenticated by necessity: a client that cannot authenticate yet
|
||||
// has to be able to read them. Neither contains a secret — they are a map of
|
||||
// public endpoints, which is exactly what discovery means.
|
||||
//
|
||||
// NO URL IS GUESSED OR HARDCODED. Every value comes from configuration, so a
|
||||
// deployment on a different host is a config change and not a code change, and
|
||||
// so this file contains no production domain.
|
||||
|
||||
// Scopes this server issues.
|
||||
//
|
||||
// ScopeWrite is DECLARED and never granted. Naming it here means the constant
|
||||
// exists for a future phase to use deliberately, rather than being invented at
|
||||
// the point somebody is trying to make a write work. It appears in no
|
||||
// scopes_supported list and no issued token.
|
||||
const (
|
||||
ScopeRead = "krow.read"
|
||||
ScopeWrite = "krow.write" // reserved; not issued, not advertised
|
||||
)
|
||||
|
||||
// Config is the deployment's OAuth identity.
|
||||
//
|
||||
// Issuer and Resource are separate values that will often look similar, and
|
||||
// conflating them is a real mistake: the ISSUER identifies the authorization
|
||||
// server, the RESOURCE identifies the thing a token is good for. A token's
|
||||
// audience is checked against Resource, and its origin against Issuer.
|
||||
type Config struct {
|
||||
// Issuer is the authorization server's identity, e.g.
|
||||
// https://api.example.com. No trailing slash.
|
||||
Issuer string
|
||||
|
||||
// Resource is the canonical MCP endpoint URI, e.g.
|
||||
// https://api.example.com/mcp. This is what a client puts in its
|
||||
// `resource` parameter and what an issued token's audience is set to.
|
||||
Resource string
|
||||
|
||||
// The paths, relative to Issuer. Defaults are applied by Normalise.
|
||||
AuthorizePath string
|
||||
TokenPath string
|
||||
RegistrationPath string
|
||||
RevocationPath string
|
||||
}
|
||||
|
||||
// Normalise fills defaults and trims trailing slashes.
|
||||
//
|
||||
// The canonical form of a resource URI has no trailing slash — RFC 8707 says
|
||||
// implementations SHOULD use that form — and a mismatch here is a token that
|
||||
// validates everywhere except the one place it was minted for.
|
||||
func (c Config) Normalise() Config {
|
||||
c.Issuer = strings.TrimRight(strings.TrimSpace(c.Issuer), "/")
|
||||
c.Resource = strings.TrimRight(strings.TrimSpace(c.Resource), "/")
|
||||
if c.AuthorizePath == "" {
|
||||
c.AuthorizePath = "/oauth/authorize"
|
||||
}
|
||||
if c.TokenPath == "" {
|
||||
c.TokenPath = "/oauth/token"
|
||||
}
|
||||
if c.RegistrationPath == "" {
|
||||
c.RegistrationPath = "/oauth/register"
|
||||
}
|
||||
if c.RevocationPath == "" {
|
||||
c.RevocationPath = "/oauth/revoke"
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Valid reports whether this configuration can serve discovery at all.
|
||||
func (c Config) Valid() bool {
|
||||
return c.Issuer != "" && c.Resource != ""
|
||||
}
|
||||
|
||||
func (c Config) authorizeURL() string { return c.Issuer + c.AuthorizePath }
|
||||
func (c Config) tokenURL() string { return c.Issuer + c.TokenPath }
|
||||
func (c Config) registrationURL() string { return c.Issuer + c.RegistrationPath }
|
||||
func (c Config) revocationURL() string { return c.Issuer + c.RevocationPath }
|
||||
|
||||
/* ── RFC 9728: Protected Resource Metadata ──────────────────────────────── */
|
||||
|
||||
type protectedResourceMetadata struct {
|
||||
Resource string `json:"resource"`
|
||||
AuthorizationServers []string `json:"authorization_servers"`
|
||||
ScopesSupported []string `json:"scopes_supported"`
|
||||
BearerMethodsSupported []string `json:"bearer_methods_supported"`
|
||||
}
|
||||
|
||||
// ProtectedResourceHandler serves /.well-known/oauth-protected-resource.
|
||||
func (c Config) ProtectedResourceHandler() http.Handler {
|
||||
cfg := c.Normalise()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", http.MethodGet)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
writeMetadata(w, protectedResourceMetadata{
|
||||
Resource: cfg.Resource,
|
||||
AuthorizationServers: []string{cfg.Issuer},
|
||||
ScopesSupported: []string{ScopeRead},
|
||||
// header only. RFC 6750 also defines a form-encoded body parameter
|
||||
// and a query parameter; the MCP spec forbids the query form and
|
||||
// this server accepts neither.
|
||||
BearerMethodsSupported: []string{"header"},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/* ── RFC 8414: Authorization Server Metadata ────────────────────────────── */
|
||||
|
||||
type authorizationServerMetadata struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
RegistrationEndpoint string `json:"registration_endpoint"`
|
||||
RevocationEndpoint string `json:"revocation_endpoint"`
|
||||
ScopesSupported []string `json:"scopes_supported"`
|
||||
ResponseTypesSupported []string `json:"response_types_supported"`
|
||||
GrantTypesSupported []string `json:"grant_types_supported"`
|
||||
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
|
||||
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
|
||||
ResourceIndicatorsSupported bool `json:"resource_indicators_supported"`
|
||||
}
|
||||
|
||||
// AuthorizationServerHandler serves /.well-known/oauth-authorization-server.
|
||||
//
|
||||
// Every list below is a promise, so each one names only what is implemented:
|
||||
//
|
||||
// - response_types: `code`. No `token`, because implicit is gone from OAuth
|
||||
// 2.1 and advertising it would invite a flow this server refuses.
|
||||
// - grant_types: authorization_code and refresh_token. No password, no
|
||||
// client_credentials — neither has a caller here, and both would be a way
|
||||
// to get a token without a person approving anything.
|
||||
// - code_challenge_methods: S256 only. Listing `plain` would tell a client it
|
||||
// may use the method this server rejects.
|
||||
// - token_endpoint_auth_methods: `none`, which is the correct declaration
|
||||
// for public clients. They authenticate with PKCE, not a secret.
|
||||
func (c Config) AuthorizationServerHandler() http.Handler {
|
||||
cfg := c.Normalise()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", http.MethodGet)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
writeMetadata(w, authorizationServerMetadata{
|
||||
Issuer: cfg.Issuer,
|
||||
AuthorizationEndpoint: cfg.authorizeURL(),
|
||||
TokenEndpoint: cfg.tokenURL(),
|
||||
RegistrationEndpoint: cfg.registrationURL(),
|
||||
RevocationEndpoint: cfg.revocationURL(),
|
||||
ScopesSupported: []string{ScopeRead},
|
||||
ResponseTypesSupported: []string{"code"},
|
||||
GrantTypesSupported: []string{"authorization_code", "refresh_token"},
|
||||
CodeChallengeMethodsSupported: []string{MethodS256},
|
||||
TokenEndpointAuthMethodsSupported: []string{"none"},
|
||||
ResourceIndicatorsSupported: true,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func writeMetadata(w http.ResponseWriter, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
// Discovery documents change only with a deployment, and a client that
|
||||
// re-reads them on every connection costs nothing to serve. Five minutes
|
||||
// keeps a stale document from outliving a config change by long.
|
||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||
writeJSONBody(w, http.StatusOK, payload)
|
||||
}
|
||||
Reference in New Issue
Block a user