feat(08-02): implement exact RFC 7591 registration in wristband

- Register validates redirect_uris/grant_types/response_types/auth-method
  in PHP's exact order, strips control characters and caps client_name at
  255 runes, and generates client_id/secret via crypto/rand base64url
- confidential clients return the raw secret once; only its sha256 hex
  persists (constant-time-comparable fixed transform)
- sweep-unconsented, the atomic cap check and the create all run inside one
  wristband.Backend.WithinTx transaction (T-08-DCR-FLOOD)
- 64 KiB body bound via http.MaxBytesReader collapses to the endpoint's
  native invalid_client_metadata body, matching D-21
This commit is contained in:
Jakub Zych
2026-09-23 19:37:54 +02:00
parent c026b83f41
commit c0b1e3cfae

View File

@@ -1,13 +1,332 @@
// RFC 7591 Dynamic Client Registration (08-02-PLAN.md Task 2, D-02/D-05/ // RFC 7591 Dynamic Client Registration, ported from PHP
// D-06/D-21). This is the Phase 8 Wave 2 RED placeholder: Register returns // OAuthRegisterController::register byte-for-byte including its validation
// 501 so TestPhase8RedRegistration fails with the PHASE8_RED:registration // order (08-CONTEXT.md D-02/D-05/D-06/D-21; canonical PHP source:
// sentinel while scripts/check-phase8-red.sh verifies the failure is // OAuthRegisterController.php).
// fail-closed, matching the 08-01-PLAN.md Task 1 RED/GREEN pattern.
package wristband package wristband
import "net/http" import (
"encoding/json"
"errors"
"net/http"
"net/url"
"strings"
)
// Register handles POST /oauth/mcp/register. var supportedAuthMethods = map[string]bool{
func (s *Server) Register(w http.ResponseWriter, r *http.Request) { "none": true,
w.WriteHeader(http.StatusNotImplemented) "client_secret_post": true,
"client_secret_basic": true,
}
var supportedGrantTypes = []string{"authorization_code", "refresh_token"}
// registerRequestBody is the RFC 7591 JSON request document. Only the
// fields PHP reads are decoded; unknown fields are ignored (PHP's
// $request->input() does the same).
type registerRequestBody struct {
RedirectURIs []string `json:"redirect_uris"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
GrantTypes []string `json:"grant_types"`
ResponseTypes []string `json:"response_types"`
ClientName string `json:"client_name"`
}
type registerResponseBody struct {
ClientID string `json:"client_id"`
ClientIDIssuedAt int64 `json:"client_id_issued_at"`
ClientName string `json:"client_name"`
RedirectURIs []string `json:"redirect_uris"`
GrantTypes []string `json:"grant_types"`
ResponseTypes []string `json:"response_types"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
ClientSecret string `json:"client_secret,omitempty"`
ClientSecretExpiresAt *int64 `json:"client_secret_expires_at,omitempty"`
}
type rfcErrorBody struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
// Register handles POST /oauth/mcp/register (RFC 7591). D-02: JSON-only,
// PHP isJson() semantics ("contains /json"). D-21: the body is bounded to
// Options.RegisterMaxBodyBytes before decoding, and an oversized body gets
// the endpoint's ordinary invalid_client_metadata response, not a generic
// error. Sweep, the cap check and the create all run inside one transaction
// (T-08-DCR-FLOOD).
func (s *Server) Register(w http.ResponseWriter, r *http.Request) {
if !isJSONContentType(r.Header.Get("Content-Type")) {
writeRegisterError(w, http.StatusBadRequest, "invalid_client_metadata", "Request must be application/json")
return
}
if s.backend == nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
r.Body = http.MaxBytesReader(w, r.Body, s.opts.RegisterMaxBodyBytes)
var body registerRequestBody
dec := json.NewDecoder(r.Body)
if err := dec.Decode(&body); err != nil {
// D-21: an oversized (http.MaxBytesReader) or otherwise malformed
// JSON body both collapse to the endpoint-native error, never a
// generic 413/house body.
writeRegisterError(w, http.StatusBadRequest, "invalid_client_metadata", "Request must be application/json")
return
}
uris, errMsg := parseRedirectURIs(body.RedirectURIs)
if errMsg != "" {
writeRegisterError(w, http.StatusBadRequest, "invalid_redirect_uri", errMsg)
return
}
authMethod := body.TokenEndpointAuthMethod
if authMethod == "" {
authMethod = "none"
}
if !supportedAuthMethods[authMethod] {
writeRegisterError(w, http.StatusBadRequest, "invalid_client_metadata", "token_endpoint_auth_method is not supported")
return
}
grants, errMsg := parseGrantTypes(body.GrantTypes)
if errMsg != "" {
writeRegisterError(w, http.StatusBadRequest, "invalid_client_metadata", errMsg)
return
}
responseTypes, errMsg := parseResponseTypes(body.ResponseTypes)
if errMsg != "" {
writeRegisterError(w, http.StatusBadRequest, "invalid_client_metadata", errMsg)
return
}
name := truncateRunes(strings.TrimSpace(stripControlChars(body.ClientName)), 255)
if name == "" {
name = "MCP client"
}
clientID, err := s.randomBytes(16)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
var secret string
var secretHash *string
if authMethod != "none" {
secret, err = s.randomBytes(32)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
h := sha256Hex(secret)
secretHash = &h
}
ip := clientIP(r)
rec := &ClientRecord{
ClientID: clientID,
ClientSecretHash: secretHash,
ClientName: name,
RedirectURIs: uris,
GrantTypes: grants,
TokenEndpointAuthMethod: authMethod,
RegistrationIP: &ip,
}
ctx := r.Context()
sweepBefore := s.now().Add(-s.opts.DCRUnconsentedSweepAge)
err = s.backend.WithinTx(ctx, func(tx Tx) error {
if err := tx.SweepUnconsented(ctx, sweepBefore); err != nil {
return err
}
return tx.CreateWithCap(ctx, rec, s.opts.DCRClientCap)
})
if errors.Is(err, ErrClientCapReached) {
writeRegisterError(w, http.StatusBadRequest, "invalid_client_metadata", "Registration temporarily unavailable")
return
}
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
resp := registerResponseBody{
ClientID: clientID,
ClientIDIssuedAt: rec.CreatedAt.Unix(),
ClientName: name,
RedirectURIs: uris,
GrantTypes: grants,
ResponseTypes: responseTypes,
TokenEndpointAuthMethod: authMethod,
}
if secret != "" {
resp.ClientSecret = secret
zero := int64(0)
resp.ClientSecretExpiresAt = &zero
}
writeExactJSON(w, http.StatusCreated, resp, map[string]string{"Cache-Control": "no-store"})
}
func writeRegisterError(w http.ResponseWriter, status int, code, description string) {
writeExactJSON(w, status, rfcErrorBody{Error: code, ErrorDescription: description}, map[string]string{"Cache-Control": "no-store"})
}
// isJSONContentType mirrors Laravel's Request::isJson(): the Content-Type
// header contains "/json" or "+json" anywhere (D-02).
func isJSONContentType(ct string) bool {
ct = strings.ToLower(ct)
return strings.Contains(ct, "/json") || strings.Contains(ct, "+json")
}
// parseRedirectURIs ports OAuthRegisterController::parseRedirectUris: a
// required, non-empty array of unique URI strings, at most 5, each passing
// rejectRedirectURI.
func parseRedirectURIs(raw []string) ([]string, string) {
if len(raw) == 0 {
return nil, "redirect_uris is required"
}
seen := make(map[string]bool, len(raw))
uris := make([]string, 0, len(raw))
for _, entry := range raw {
if entry == "" {
return nil, "redirect_uris must be an array of URI strings"
}
if seen[entry] {
continue
}
seen[entry] = true
uris = append(uris, entry)
}
if len(uris) > 5 {
return nil, "A client may have at most 5 redirect URIs"
}
for _, u := range uris {
if reason := rejectRedirectURI(u); reason != "" {
return nil, reason
}
}
return uris, ""
}
// rejectRedirectURI ports OAuthClient::rejectRedirectUri: at most 512
// characters, a valid URL with scheme+host, https:// or loopback
// http://127.0.0.1 / http://localhost. Returns "" when the URI is accepted.
func rejectRedirectURI(uri string) string {
if len(uri) > 512 {
return "Each redirect URI must be at most 512 characters."
}
parsed, err := url.Parse(uri)
if err != nil || parsed.Scheme == "" || parsed.Hostname() == "" {
return "Redirect URI is not a valid URL: " + uri
}
scheme := strings.ToLower(parsed.Scheme)
host := strings.ToLower(parsed.Hostname())
if scheme == "https" {
return ""
}
if scheme == "http" && (host == "127.0.0.1" || host == "localhost") {
return ""
}
return "Redirect URI must be https:// or loopback http://127.0.0.1 / http://localhost: " + uri
}
// parseGrantTypes ports OAuthRegisterController::parseGrantTypes: nil means
// the default (both supported grants); otherwise a non-empty array of
// supported values that must include authorization_code.
func parseGrantTypes(raw []string) ([]string, string) {
if raw == nil {
return append([]string(nil), supportedGrantTypes...), ""
}
if len(raw) == 0 {
return nil, "grant_types is invalid"
}
seen := make(map[string]bool, len(raw))
grants := make([]string, 0, len(raw))
hasAuthCode := false
for _, entry := range raw {
if !isSupportedGrantType(entry) {
return nil, "grant_types contains an unsupported value"
}
if seen[entry] {
continue
}
seen[entry] = true
grants = append(grants, entry)
if entry == "authorization_code" {
hasAuthCode = true
}
}
if !hasAuthCode {
return nil, "grant_types must include authorization_code"
}
return grants, ""
}
func isSupportedGrantType(v string) bool {
for _, g := range supportedGrantTypes {
if g == v {
return true
}
}
return false
}
// parseResponseTypes ports OAuthRegisterController::parseResponseTypes:
// nil means the default ["code"]; otherwise every entry must be "code".
func parseResponseTypes(raw []string) ([]string, string) {
if raw == nil {
return []string{"code"}, ""
}
if len(raw) == 0 {
return nil, "response_types is invalid"
}
for _, entry := range raw {
if entry != "code" {
return nil, "response_types must be code"
}
}
return []string{"code"}, ""
}
// stripControlChars removes C0 control characters and DEL, mirroring the
// PHP preg_replace('/[\x00-\x1F\x7F]/u', ”, ...) regex ConnectedApp/Consent
// controllers apply to client_name at display time (08-CONTEXT.md
// discretion note). Registration applies the same filter at capture time so
// no untrusted control byte from an issuer's declared name is ever stored.
func stripControlChars(s string) string {
return strings.Map(func(r rune) rune {
if r <= 0x1F || r == 0x7F {
return -1
}
return r
}, s)
}
// truncateRunes returns s truncated to at most n runes (PHP mb_substr parity).
func truncateRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n])
}
// clientIP mirrors PHP's substr((string) $request->ip(), 0, 45): the
// direct remote address (no trusted-proxy chain here; DCR is unauthenticated
// and app-specific proxy trust stays out of the app-agnostic framework).
func clientIP(r *http.Request) string {
host := r.RemoteAddr
if idx := strings.LastIndex(host, ":"); idx != -1 && !strings.Contains(host, "]") {
host = host[:idx]
} else if strings.HasPrefix(host, "[") {
if end := strings.Index(host, "]"); end != -1 {
host = host[1:end]
}
}
if len(host) > 45 {
host = host[:45]
}
return host
} }