Files
Aravind f2aa3b3ad8
Some checks failed
CI / fixture (push) Has been cancelled
CI / test (push) Has been cancelled
mcp connection
2026-09-22 10:58:02 +05:30

187 lines
8.0 KiB
Go

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)
}