// 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" ) // authorizeAllowedScopes mirrors PHP OAuthAuthorizeController::ALLOWED_SCOPES. var authorizeAllowedScopes = map[string]bool{ "read": true, "write": true, "ai": true, "offline_access": true, } // 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. func (s *Server) Authorize(w http.ResponseWriter, r *http.Request) { 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 } } if client == nil || client.RevokedAt != nil { 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")) 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)) } // parseAuthorizeScopes ports OAuthAuthorizeController::parseScopes. An // absent or empty scope query value defaults to ["read"]; otherwise every // whitespace-separated token must be one of authorizeAllowedScopes. func parseAuthorizeScopes(raw string) ([]string, string) { parts := strings.Fields(strings.TrimSpace(raw)) if len(parts) == 0 { return []string{"read"}, "" } for _, p := range parts { if !authorizeAllowedScopes[p] { return nil, "scope contains an unsupported value" } } return parts, "" } // 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) }