// Package wristband implements the app-agnostic RFC 8414 / OAuth // authorization-server surface ported from Płytarium's hand-rolled PHP OAuth // server (08-CONTEXT.md D-05). It never imports an application package, a // GORM type, or any fonoteka model: every deployment-specific value (issuer, // scopes, endpoint paths, TTLs) arrives through Options, and every app-owned // concern (users, collections, persistence) stays out of this package. // // D-06: PHP's RFC-minimal response shapes are wristband's defaults. There // are no response hooks; callers cannot alter the wire bytes beyond the // values exposed on Options. package wristband import ( "bytes" "encoding/json" "net/http" "time" ) // Options configures a Server's advertised endpoints and metadata values. // Every field has a PHP-parity default via DefaultOptions except Issuer, // which the caller must set from app.url with its trailing slash trimmed // exactly once (D-03). wristband never hardcodes an app's issuer. type Options struct { // Issuer is app.url with exactly one trailing slash trimmed by the // caller. Every metadata endpoint URL is built by appending a fixed // RFC path suffix to Issuer. Issuer string // ServiceDocumentationPath is appended to Issuer for the metadata // service_documentation field. PHP default: "/help". ServiceDocumentationPath string // ScopesSupported is the RFC 8414 scopes_supported list. PHP default: // ["read","write","ai","offline_access"]. ScopesSupported []string // TokenEndpointAuthMethodsSupported is the RFC 8414 // token_endpoint_auth_methods_supported list. PHP default: // ["none","client_secret_post","client_secret_basic"]. TokenEndpointAuthMethodsSupported []string // AuthorizationResponseIssParameterSupported is the RFC 9207 metadata // capability flag. PHP default: true. AuthorizationResponseIssParameterSupported bool // DCRClientCap is the maximum number of unrevoked OAuth clients RFC // 7591 registration allows (PHP OAuthRegisterController::MAX_CLIENTS). // PHP default: 200 (D-03). DCRClientCap int // DCRUnconsentedSweepAge is how old an unconsented, dynamically // registered client (non-nil RegistrationIP) must be before // registration sweeps it. PHP default: 24h (D-03). DCRUnconsentedSweepAge time.Duration // RegisterMaxBodyBytes bounds the RFC 7591 registration request body // before JSON decoding (D-21, T-08-DCR-FLOOD). PHP default: 65536 (64 KiB). RegisterMaxBodyBytes int64 // Resource is the expected RFC 8707 resource indicator value authorize // checks an optional resource query parameter against (PHP // config('fonoteka.mcp.resource'), D-03). PHP default: // "https://mcp.plytarium.com/mcp". Resource string // PendingRequestTTL is how long a pre-consent pending authorization row // created by authorize stays valid (PHP // OAuthCodeManager::PENDING_TTL_SECONDS, D-03). PHP default: 600s. PendingRequestTTL time.Duration // CodeTTL is how long an issued authorization code stays valid after // consent (PHP OAuthCodeManager::CODE_TTL_SECONDS, D-03). PHP default: // 600s. Consent issuance always sets a fresh expiry from this TTL // rather than reusing the pending row's original expiry. CodeTTL time.Duration // AccessTokenTTL is how long an inv_ access token minted by a successful // code exchange or refresh rotation stays valid (PHP // OAuthCodeManager::ACCESS_TTL_SECONDS, D-03). PHP default: 3600s (1h). AccessTokenTTL time.Duration // RefreshTokenTTL is how long a refresh-token lineage row stays valid // from issuance (PHP OAuthCodeManager::REFRESH_TTL_DAYS, D-03). PHP // default: 30 days. RefreshTokenTTL time.Duration } // DefaultOptions returns PHP-parity defaults for every metadata option // other than Issuer, which the caller must set from app.url. func DefaultOptions() Options { return Options{ ServiceDocumentationPath: "/help", ScopesSupported: []string{"read", "write", "ai", "offline_access"}, TokenEndpointAuthMethodsSupported: []string{"none", "client_secret_post", "client_secret_basic"}, AuthorizationResponseIssParameterSupported: true, DCRClientCap: 200, DCRUnconsentedSweepAge: 24 * time.Hour, RegisterMaxBodyBytes: 65536, Resource: "https://mcp.plytarium.com/mcp", PendingRequestTTL: 600 * time.Second, CodeTTL: 600 * time.Second, AccessTokenTTL: 3600 * time.Second, RefreshTokenTTL: 30 * 24 * time.Hour, } } // Server is the app-agnostic wristband authorization-server surface. It is // constructed with Options and never imports an application package. type Server struct { opts Options backend Backend // now and randomBytes are deterministic clock/entropy seams so tests // can control timestamps and generated secrets without depending on // wall-clock time or true randomness (08-02-PLAN.md Task 2). now func() time.Time randomBytes func(n int) (string, error) } // NewServer constructs a Server from Options. The backend is nil until // SetBackend is called (D-09: the metadata route needs no backend at all, // so plugin boot can construct a Server before a *gorm.DB is available). func NewServer(opts Options) *Server { return &Server{ opts: opts, now: time.Now, randomBytes: randomBase64URL, } } // SetBackend attaches the app's transaction-scoped store bundle. Handlers // that need persistence (Register) return an opaque 500 until this is // called. func (s *Server) SetBackend(b Backend) { s.backend = b } // metadataDocument is the exact unwrapped RFC 8414 body. Field order matches // the PHP array literal in OAuthMetadataController::show() byte for byte; // encoding/json preserves struct declaration order, so this struct is the // single source of truth for the wire order. type metadataDocument struct { Issuer string `json:"issuer"` AuthorizationEndpoint string `json:"authorization_endpoint"` TokenEndpoint string `json:"token_endpoint"` RegistrationEndpoint string `json:"registration_endpoint"` 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"` ScopesSupported []string `json:"scopes_supported"` ServiceDocumentation string `json:"service_documentation"` AuthorizationResponseIssParameterSupported bool `json:"authorization_response_iss_parameter_supported"` } // Metadata handles GET /.well-known/oauth-authorization-server, writing the // exact unwrapped RFC 8414 document (D-06). response_types_supported, // grant_types_supported and code_challenge_methods_supported are fixed // protocol constants, not Options: this phase's authorization server only // ever supports the authorization_code/refresh_token grants with S256 PKCE // (D-01), so there is nothing app-specific to configure there. func (s *Server) Metadata(w http.ResponseWriter, r *http.Request) { doc := metadataDocument{ Issuer: s.opts.Issuer, AuthorizationEndpoint: s.opts.Issuer + "/oauth/mcp/authorize", TokenEndpoint: s.opts.Issuer + "/oauth/mcp/token", RegistrationEndpoint: s.opts.Issuer + "/oauth/mcp/register", ResponseTypesSupported: []string{"code"}, GrantTypesSupported: []string{"authorization_code", "refresh_token"}, CodeChallengeMethodsSupported: []string{"S256"}, TokenEndpointAuthMethodsSupported: s.opts.TokenEndpointAuthMethodsSupported, ScopesSupported: s.opts.ScopesSupported, ServiceDocumentation: s.opts.Issuer + s.opts.ServiceDocumentationPath, AuthorizationResponseIssParameterSupported: s.opts.AuthorizationResponseIssParameterSupported, } writeExactJSON(w, http.StatusOK, doc, map[string]string{"Cache-Control": "no-cache, private"}) } // writeExactJSON writes v as an unwrapped, no-trailing-newline JSON document // (matching the wire/response.go WriteJSON technique) but never falls back to // the house opaque-500 envelope: raw RFC responses must never acquire a // house-shaped body (D-06/D-09). func writeExactJSON(w http.ResponseWriter, status int, v any, extraHeaders map[string]string) { var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) if err := enc.Encode(v); err != nil { w.WriteHeader(http.StatusInternalServerError) return } h := w.Header() h.Set("Content-Type", "application/json") for k, v := range extraHeaders { h.Set(k, v) } w.WriteHeader(status) _, _ = w.Write(bytes.TrimSuffix(buf.Bytes(), []byte("\n"))) }