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