187 lines
8.0 KiB
Go
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)
|
|
}
|