- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
273 lines
8.7 KiB
Go
273 lines
8.7 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"
|
|
)
|
|
|
|
// 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)
|
|
}
|