Files
summercms/wristband/server.go
Jakub Zych c578bb58d7 feat(08-01): implement exact RFC 8414 metadata writer in wristband
- Server.Metadata now writes the unwrapped 11-field PHP-parity document
  through a local no-envelope, no-trailing-newline JSON writer with the
  PHP Cache-Control: no-cache, private header (D-06); response types,
  grant types and PKCE method stay fixed protocol constants
- TestPhase8RedMetadata now passes; TestMetadataExactBytes and
  TestMetadataUsesConfiguredOptions cover byte-exact output and the four
  configurable Options fields
2026-09-23 19:10:26 +02:00

130 lines
6.0 KiB
Go

// 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"
)
// 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
}
// 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,
}
}
// 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
}
// NewServer constructs a Server from Options.
func NewServer(opts Options) *Server {
return &Server{opts: opts}
}
// 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")))
}