Files
summercms/modules/wristband/authorize.go
Jakub Zych aba678b196 fix(wristband): drop OIDC identity scopes at MCP authorize
Clients that also read the issuer's OpenID Connect discovery document
add openid, profile or email to their MCP authorize request, and the
MCP flavour refused those requests with invalid_scope ("scope contains
an unsupported value"), so they could not connect.

The three identity scopes are now dropped before the allow-list check,
as RFC 6749 section 3.3 lets a server grant fewer scopes than requested.
A request left with no other scope gets the read default. Any other
unknown scope, including a differently cased identity name, is still
refused. The MCP flavour still never grants identity scopes or issues
an id_token, and the metadata document is unchanged.
2026-11-06 14:17:43 +01:00

316 lines
10 KiB
Go

// RFC 6749 authorization endpoint for MCP OAuth, ported from PHP
// OAuthAuthorizeController::authorize byte-for-byte including its
// validation order (08-CONTEXT.md D-02/D-04/D-05; canonical PHP source:
// OAuthAuthorizeController.php).
//
// D-02: query-only parsing (no body is ever read). The client and the exact
// registered redirect URI are validated before any redirect response is
// constructed (T-08-OPEN-REDIRECT): an unknown client or unregistered
// redirect is a local text/plain 400 with no Location header. Every later
// failure redirects to the now-trusted redirect_uri with an ordered
// error/error_description/iss[/state] query built through an RFC 3986
// encoder, never url.Values.Encode (08-RESEARCH.md Pattern 3/Pitfall 5).
package wristband
import (
"fmt"
"net/http"
"strings"
)
// allowedAuthorizeScopes is the set parseAuthorizeScopes accepts on this
// server: the same resolvedScopes slice Metadata advertises. A non-empty
// Options.ScopesSupported is used as given, so a host that lists an extra
// scope accepts it and a list without backend still refuses it. An empty
// list on the MCP flavour resolves to the five fixed names, backend
// included. Scope resolution runs after the client, redirect and PKCE
// checks in Authorize.
func (s *Server) allowedAuthorizeScopes() map[string]bool {
scopes := s.resolvedScopes()
allowed := make(map[string]bool, len(scopes))
for _, sc := range scopes {
if sc = strings.TrimSpace(sc); sc != "" {
allowed[sc] = true
}
}
return allowed
}
// Authorize handles GET /oauth/mcp/authorize (D-09: raw route, no
// middleware). It validates the client and exact redirect before any
// redirect response, enforces S256 PKCE syntax, the client's scope ceiling,
// and the RFC 8707 resource check, then persists an opaque pending request
// and redirects to the app's /connect handoff.
//
// On an OIDC server it dispatches to the OIDC flavour before any MCP code
// runs: GET starts an authorization request, POST is the consent decision.
func (s *Server) Authorize(w http.ResponseWriter, r *http.Request) {
if s.flavour == flavourOIDC {
s.oidcAuthorize(w, r)
return
}
if s.backend == nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
ctx := r.Context()
q := r.URL.Query()
clientID := q.Get("client_id")
var client *ClientRecord
if clientID != "" {
err := s.backend.WithinTx(ctx, func(tx Tx) error {
c, err := tx.ByClientID(ctx, clientID)
if err != nil {
return err
}
client = c
return nil
})
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
}
// D-08: an OpenID Connect client is unknown to the MCP flavour.
if client == nil || client.RevokedAt != nil || client.Kind == ClientKindOIDC {
writeAuthorizeLocalError(w, "Unknown client.")
return
}
redirectURI := q.Get("redirect_uri")
if redirectURI == "" || !stringSliceContains(client.RedirectURIs, redirectURI) {
writeAuthorizeLocalError(w, "Unregistered redirect URI.")
return
}
var state *string
if v := q.Get("state"); v != "" {
state = &v
}
if q.Get("response_type") != "code" {
s.authorizeErrorRedirect(w, redirectURI, "unsupported_response_type", "response_type must be code", state)
return
}
if q.Get("code_challenge_method") != "S256" {
s.authorizeErrorRedirect(w, redirectURI, "invalid_request", "code_challenge_method must be S256", state)
return
}
challenge := q.Get("code_challenge")
if l := len(challenge); l < 43 || l > 128 {
s.authorizeErrorRedirect(w, redirectURI, "invalid_request", "code_challenge is required", state)
return
}
scopes, scopeErr := parseAuthorizeScopes(q.Get("scope"), s.allowedAuthorizeScopes())
if scopeErr != "" {
s.authorizeErrorRedirect(w, redirectURI, "invalid_scope", scopeErr, state)
return
}
// Phase 07.13 (D-10/D-11/D-12 in the PHP source comment): a nil ceiling
// is a no-op; offline_access always survives truncation because it is
// peeled into a boolean below, not a data scope; truncating here (not
// rejecting) means an over-broad request silently narrows instead of
// failing, except when nothing data-bearing survives.
if client.ScopeCeiling != nil {
kept := make([]string, 0, len(scopes))
for _, sc := range scopes {
if sc == "offline_access" || stringSliceContains(client.ScopeCeiling, sc) {
kept = append(kept, sc)
}
}
dataScopes := make([]string, 0, len(kept))
for _, sc := range kept {
if sc != "offline_access" {
dataScopes = append(dataScopes, sc)
}
}
if len(dataScopes) == 0 {
s.authorizeErrorRedirect(w, redirectURI, "invalid_scope", "requested scope is outside this client's ceiling", state)
return
}
scopes = kept
}
var resource *string
if v := q.Get("resource"); v != "" {
resource = &v
}
if resource != nil && *resource != s.opts.Resource {
s.authorizeErrorRedirect(w, redirectURI, "invalid_target", "resource does not match this server", state)
return
}
// createPendingRequest (PHP OAuthCodeManager): peel offline_access into
// a boolean flag; the persisted scopes list never contains it.
offline := false
dataScopes := make([]string, 0, len(scopes))
for _, sc := range scopes {
if sc == "offline_access" {
offline = true
continue
}
dataScopes = append(dataScopes, sc)
}
requestID, err := s.randomBytes(32)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
rec := &AuthCodeRecord{
RequestID: &requestID,
ClientID: client.ClientID,
RedirectURI: redirectURI,
Scopes: dataScopes,
CodeChallenge: challenge,
CodeChallengeMethod: "S256",
Resource: resource,
State: state,
ExpiresAt: s.now().Add(s.opts.PendingRequestTTL),
OfflineAccess: offline,
}
err = s.backend.WithinTx(ctx, func(tx Tx) error {
return tx.CreatePending(ctx, rec)
})
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
spa := s.opts.Issuer + "/connect?request=" + rfc3986Escape(requestID)
// Laravel appends ", private" to every explicit Cache-Control this
// endpoint sets (session-cookie default merge); recorded PHP traffic is
// "no-store, private", never a bare "no-store" (D-04, live-recorded byte
// contract, 08-09-PLAN.md Task 2).
w.Header().Set("Cache-Control", "no-store, private")
writeRedirectHTML(w, http.StatusFound, spa)
}
// writeAuthorizeLocalError writes the PHP localError() response: a bare
// text/plain 400 with no Location and no house envelope
// (T-08-OPEN-REDIRECT).
func writeAuthorizeLocalError(w http.ResponseWriter, message string) {
w.Header().Set("Cache-Control", "no-store, private")
w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(message))
}
// authorizeErrorRedirect ports OAuthAuthorizeController::errorRedirect: an
// ordered error/error_description/iss[/state] query appended to the
// already-trusted redirectURI via RFC 3986 encoding (D-04/Pattern 3).
func (s *Server) authorizeErrorRedirect(w http.ResponseWriter, redirectURI, errCode, description string, state *string) {
pairs := [][2]string{
{"error", errCode},
{"error_description", description},
{"iss", s.opts.Issuer},
}
if state != nil {
pairs = append(pairs, [2]string{"state", *state})
}
w.Header().Set("Cache-Control", "no-store, private")
writeRedirectHTML(w, http.StatusFound, appendOrderedQuery(redirectURI, pairs))
}
// mcpIgnoredIdentityScopes are the OpenID Connect identity scopes the MCP
// flavour drops from an authorize request before validating it. The match
// is exact and case-sensitive.
var mcpIgnoredIdentityScopes = map[string]bool{
"openid": true,
"profile": true,
"email": true,
}
// parseAuthorizeScopes ports OAuthAuthorizeController::parseScopes with one
// deliberate divergence. The identity scopes openid, profile and email are
// dropped first, because clients that also read the issuer's OpenID Connect
// discovery document request them on the MCP endpoint, and RFC 6749 section
// 3.3 lets the server grant fewer scopes than requested. The MCP flavour
// issues no ID tokens and never grants identity scopes (phase 19.4 D-07,
// narrowed from refusal to dropping), so the drop happens even when a host
// lists one of them in Options.ScopesSupported. An absent, empty or
// identity-only scope value falls back to ["read"]; any other token outside
// allowed, the server's set (allowedAuthorizeScopes), is refused.
func parseAuthorizeScopes(raw string, allowed map[string]bool) ([]string, string) {
parts := strings.Fields(strings.TrimSpace(raw))
kept := make([]string, 0, len(parts))
for _, p := range parts {
if !mcpIgnoredIdentityScopes[p] {
kept = append(kept, p)
}
}
if len(kept) == 0 {
return []string{"read"}, ""
}
for _, p := range kept {
if !allowed[p] {
return nil, "scope contains an unsupported value"
}
}
return kept, ""
}
// stringSliceContains reports whether ss contains v.
func stringSliceContains(ss []string, v string) bool {
for _, s := range ss {
if s == v {
return true
}
}
return false
}
// rfc3986Escape percent-encodes s per RFC 3986 (PHP rawurlencode/
// PHP_QUERY_RFC3986): unreserved characters A-Z a-z 0-9 - _ . ~ pass
// through unescaped; everything else, including the space byte, becomes an
// uppercase %XX triplet. This intentionally differs from
// net/url.QueryEscape (form-style '+' for space, different reserved-char
// handling), which would corrupt the exact PHP redirect-byte contract
// (08-RESEARCH.md Pattern 3/Pitfall 5).
func rfc3986Escape(s string) string {
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); i++ {
c := s[i]
if isRFC3986Unreserved(c) {
b.WriteByte(c)
} else {
fmt.Fprintf(&b, "%%%02X", c)
}
}
return b.String()
}
func isRFC3986Unreserved(c byte) bool {
return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
c == '-' || c == '_' || c == '.' || c == '~'
}
// buildOrderedQuery RFC3986-encodes and joins pairs in the given order
// (never net/url.Values.Encode, which sorts keys and space-encodes as '+').
func buildOrderedQuery(pairs [][2]string) string {
parts := make([]string, len(pairs))
for i, p := range pairs {
parts[i] = rfc3986Escape(p[0]) + "=" + rfc3986Escape(p[1])
}
return strings.Join(parts, "&")
}
// appendOrderedQuery ports OAuthAuthorizeController::appendQuery: appends an
// ordered RFC 3986 query to uri, using '&' when uri already carries a query
// string and '?' otherwise.
func appendOrderedQuery(uri string, pairs [][2]string) string {
sep := "?"
if strings.Contains(uri, "?") {
sep = "&"
}
return uri + sep + buildOrderedQuery(pairs)
}