refactor(10.2-01): nest framework packages under modules
- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
This commit is contained in:
199
modules/wristband/server.go
Normal file
199
modules/wristband/server.go
Normal file
@@ -0,0 +1,199 @@
|
||||
// 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")))
|
||||
}
|
||||
Reference in New Issue
Block a user