test(08-02): add failing RFC 7591 registration RED test in wristband

- TestPhase8RedRegistration asserts the exact public-client DCR success
  contract and fails while Server.Register is a 501 stub
- adds the Backend/Tx transaction-scoped store bundle (ClientStore,
  AuthCodeStore, RefreshTokenStore, AccessTokenIssuer) and wristband's own
  in-memory implementation for framework-level tests (D-07)
- adds crypto.go's fixed-transform helpers (random base64url, sha256 hex,
  constant-time compare, S256) and Options/Server seams for the DCR
  lifetimes, cap, sweep age and 64 KiB body bound (D-03/D-21)
This commit is contained in:
Jakub Zych
2026-09-23 19:37:03 +02:00
parent deee2cc8d7
commit c026b83f41
5 changed files with 766 additions and 3 deletions

41
wristband/crypto.go Normal file
View File

@@ -0,0 +1,41 @@
package wristband
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
)
// randomBase64URL returns n cryptographically random bytes, base64url
// (RawURLEncoding, no padding) encoded, matching PHP's
// rtrim(strtr(base64_encode(random_bytes(n)), '+/', '-_'), '=') byte for
// byte (D-01/D-04).
func randomBase64URL(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// sha256Hex is the fixed transform every opaque secret (client secret, code,
// refresh token) is compared and persisted through: never the raw variable-
// length secret (D-04).
func sha256Hex(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
// constantEqual compares two fixed-transform strings (sha256 hex digests or
// PKCE S256 challenges) in constant time (D-04; RFC 7636 verifier compare).
func constantEqual(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
// s256Challenge is the RFC 7636 S256 transform: base64url(sha256(verifier)).
func s256Challenge(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}

13
wristband/register.go Normal file
View File

@@ -0,0 +1,13 @@
// RFC 7591 Dynamic Client Registration (08-02-PLAN.md Task 2, D-02/D-05/
// D-06/D-21). This is the Phase 8 Wave 2 RED placeholder: Register returns
// 501 so TestPhase8RedRegistration fails with the PHASE8_RED:registration
// sentinel while scripts/check-phase8-red.sh verifies the failure is
// fail-closed, matching the 08-01-PLAN.md Task 1 RED/GREEN pattern.
package wristband
import "net/http"
// Register handles POST /oauth/mcp/register.
func (s *Server) Register(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
}

View File

@@ -0,0 +1,532 @@
package wristband
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// memoryBackend is wristband's own in-memory Backend/Tx implementation
// (08-CONTEXT.md D-07: "wristband ships an in-memory store for its own
// tests"). One mutex guards the whole transaction closure so concurrency
// tests model the seam, not individual map operations (08-PATTERNS.md).
type memoryBackend struct {
mu sync.Mutex
clients []*ClientRecord
codes []*AuthCodeRecord
refresh []*RefreshTokenRecord
tokens []*IssuedToken
nextID uint
}
func newMemoryBackend() *memoryBackend {
return &memoryBackend{}
}
func (b *memoryBackend) WithinTx(ctx context.Context, fn func(Tx) error) error {
b.mu.Lock()
defer b.mu.Unlock()
return fn(&memoryTx{b: b})
}
type memoryTx struct{ b *memoryBackend }
var _ Tx = (*memoryTx)(nil)
func (t *memoryTx) ByClientID(ctx context.Context, clientID string) (*ClientRecord, error) {
for _, c := range t.b.clients {
if c.ClientID == clientID {
cp := *c
return &cp, nil
}
}
return nil, nil
}
func (t *memoryTx) CreateWithCap(ctx context.Context, rec *ClientRecord, cap int) error {
count := 0
for _, c := range t.b.clients {
if c.RevokedAt == nil {
count++
}
}
if count >= cap {
return ErrClientCapReached
}
t.b.nextID++
rec.ID = t.b.nextID
rec.CreatedAt = time.Now()
cp := *rec
t.b.clients = append(t.b.clients, &cp)
return nil
}
func (t *memoryTx) SweepUnconsented(ctx context.Context, olderThan time.Time) error {
kept := t.b.clients[:0:0]
for _, c := range t.b.clients {
if c.ConsentedAt == nil && c.RegistrationIP != nil && c.CreatedAt.Before(olderThan) {
continue
}
kept = append(kept, c)
}
t.b.clients = kept
return nil
}
func (t *memoryTx) CreatePending(ctx context.Context, rec *AuthCodeRecord) error {
t.b.nextID++
rec.ID = t.b.nextID
cp := *rec
t.b.codes = append(t.b.codes, &cp)
return nil
}
func (t *memoryTx) ByRequestID(ctx context.Context, requestID string) (*AuthCodeRecord, error) {
for _, c := range t.b.codes {
if c.RequestID != nil && *c.RequestID == requestID {
cp := *c
return &cp, nil
}
}
return nil, nil
}
func (t *memoryTx) ByCodeHashForUpdate(ctx context.Context, codeHash string) (*AuthCodeRecord, error) {
for _, c := range t.b.codes {
if c.CodeHash != nil && *c.CodeHash == codeHash {
cp := *c
return &cp, nil
}
}
return nil, nil
}
func (t *memoryTx) MarkIssued(ctx context.Context, id uint, codeHash string, userID uint) error {
for _, c := range t.b.codes {
if c.ID == id {
c.RequestID = nil
c.CodeHash = &codeHash
c.UserID = &userID
return nil
}
}
return nil
}
func (t *memoryTx) MarkUsed(ctx context.Context, id uint) error {
now := time.Now()
for _, c := range t.b.codes {
if c.ID == id {
c.UsedAt = &now
return nil
}
}
return nil
}
func (t *memoryTx) Create(ctx context.Context, rec *RefreshTokenRecord) error {
t.b.nextID++
rec.ID = t.b.nextID
cp := *rec
t.b.refresh = append(t.b.refresh, &cp)
return nil
}
func (t *memoryTx) ByTokenHashForUpdate(ctx context.Context, tokenHash string) (*RefreshTokenRecord, error) {
for _, r := range t.b.refresh {
if r.TokenHash == tokenHash {
cp := *r
return &cp, nil
}
}
return nil, nil
}
func (t *memoryTx) RevokeLineage(ctx context.Context, startID uint) error {
now := time.Now()
id := startID
for {
var found *RefreshTokenRecord
for _, r := range t.b.refresh {
if r.ID == id {
found = r
break
}
}
if found == nil {
return nil
}
if found.RevokedAt == nil {
found.RevokedAt = &now
}
if found.RotatedToID == nil {
return nil
}
id = *found.RotatedToID
}
}
func (t *memoryTx) Mint(ctx context.Context, userID uint, name string, scopes []string, expiresAt time.Time, collectionIDs []uint, clientID string) (IssuedToken, error) {
t.b.nextID++
tok := IssuedToken{ID: t.b.nextID, Secret: "mem_" + name}
t.b.tokens = append(t.b.tokens, &tok)
return tok, nil
}
func (t *memoryTx) Revoke(ctx context.Context, tokenID uint) error {
return nil
}
func newTestServer(backend Backend) *Server {
opts := DefaultOptions()
opts.Issuer = "https://plytarium.com"
srv := NewServer(opts)
srv.SetBackend(backend)
return srv
}
func registerRequest(t *testing.T, body string, contentType string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/register", strings.NewReader(body))
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
return rec
}
// TestPhase8RedRegistration is the Phase 8 Wave 2 RED anchor (08-02-PLAN.md
// Task 2, D-02/D-05/D-06/D-21). It asserts the exact RFC 7591 public-client
// success contract and fails with the PHASE8_RED:registration sentinel
// while Server.Register is a 501 stub; scripts/check-phase8-red.sh verifies
// this failure is fail-closed.
func TestPhase8RedRegistration(t *testing.T) {
srv := newTestServer(newMemoryBackend())
body := `{"redirect_uris":["https://client.example.test/callback"]}`
req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/register", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.Register(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("PHASE8_RED:registration: status = %d, want %d (body=%s)", rec.Code, http.StatusCreated, rec.Body.String())
}
var got map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("PHASE8_RED:registration: decode response: %v", err)
}
if got["client_name"] != "MCP client" {
t.Fatalf("PHASE8_RED:registration: client_name = %v, want default \"MCP client\"", got["client_name"])
}
if _, hasSecret := got["client_secret"]; hasSecret {
t.Fatal("PHASE8_RED:registration: public client response has a client_secret, want none")
}
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
t.Fatalf("PHASE8_RED:registration: Cache-Control = %q, want \"no-store\"", cc)
}
}
func TestRegisterPublicClientResponse(t *testing.T) {
srv := newTestServer(newMemoryBackend())
body := `{"redirect_uris":["https://client.example.test/callback"],"client_name":"My Client"}`
req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/register", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.Register(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got["client_name"] != "My Client" {
t.Fatalf("client_name = %v", got["client_name"])
}
if got["token_endpoint_auth_method"] != "none" {
t.Fatalf("token_endpoint_auth_method = %v, want none", got["token_endpoint_auth_method"])
}
if _, hasSecret := got["client_secret"]; hasSecret {
t.Fatal("public client response carries client_secret")
}
if _, hasExpires := got["client_secret_expires_at"]; hasExpires {
t.Fatal("public client response carries client_secret_expires_at")
}
if strings.HasSuffix(rec.Body.String(), "\n") {
t.Fatal("body has a trailing newline")
}
if _, hasData := got["data"]; hasData {
t.Fatal("body has a house \"data\" envelope")
}
}
func TestRegisterConfidentialClientReturnsSecretOnce(t *testing.T) {
backend := newMemoryBackend()
srv := newTestServer(backend)
body := `{"redirect_uris":["https://client.example.test/callback"],"token_endpoint_auth_method":"client_secret_post"}`
req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/register", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.Register(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
secret, _ := got["client_secret"].(string)
if secret == "" {
t.Fatal("confidential client response has no client_secret")
}
if got["client_secret_expires_at"] != float64(0) {
t.Fatalf("client_secret_expires_at = %v, want 0", got["client_secret_expires_at"])
}
backend.mu.Lock()
defer backend.mu.Unlock()
if len(backend.clients) != 1 {
t.Fatalf("stored clients = %d, want 1", len(backend.clients))
}
stored := backend.clients[0]
if stored.ClientSecretHash == nil || *stored.ClientSecretHash == secret {
t.Fatal("stored record must hold a hash, never the raw secret")
}
if *stored.ClientSecretHash != sha256Hex(secret) {
t.Fatal("stored hash does not match sha256Hex(secret)")
}
if strings.Contains(rec.Body.String(), *stored.ClientSecretHash) {
t.Fatal("response body must not also contain the persisted hash")
}
}
func TestRegisterWrongContentType(t *testing.T) {
srv := newTestServer(newMemoryBackend())
body := `redirect_uris=https://client.example.test/callback`
req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/register", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
srv.Register(rec, req)
assertRegisterError(t, rec, http.StatusBadRequest, "invalid_client_metadata", "Request must be application/json")
}
func TestRegisterMalformedJSON(t *testing.T) {
srv := newTestServer(newMemoryBackend())
req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/register", strings.NewReader(`{not json`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.Register(rec, req)
assertRegisterError(t, rec, http.StatusBadRequest, "invalid_client_metadata", "Request must be application/json")
}
func TestRegisterOversizedBody(t *testing.T) {
srv := newTestServer(newMemoryBackend())
srv.opts.RegisterMaxBodyBytes = 64 * 1024
// D-21: exactly one byte past the 64 KiB bound.
padding := strings.Repeat("a", 65537)
body := `{"redirect_uris":["https://client.example.test/callback"],"client_name":"` + padding + `"}`
req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/register", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.Register(rec, req)
assertRegisterError(t, rec, http.StatusBadRequest, "invalid_client_metadata", "Request must be application/json")
}
func TestRegisterRedirectURIBounds(t *testing.T) {
cases := []struct {
name string
uris string
want string
}{
{"missing", `[]`, "redirect_uris is required"},
{"empty-entry", `["https://a.test/cb",""]`, "redirect_uris must be an array of URI strings"},
{"too-many", `["https://a.test/1","https://a.test/2","https://a.test/3","https://a.test/4","https://a.test/5","https://a.test/6"]`, "A client may have at most 5 redirect URIs"},
{"http-non-loopback", `["http://evil.example.test/cb"]`, "Redirect URI must be https:// or loopback http://127.0.0.1 / http://localhost: http://evil.example.test/cb"},
{"not-a-url", `["not-a-url"]`, "Redirect URI is not a valid URL: not-a-url"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := newTestServer(newMemoryBackend())
body := `{"redirect_uris":` + tc.uris + `}`
req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/register", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.Register(rec, req)
assertRegisterError(t, rec, http.StatusBadRequest, "invalid_redirect_uri", tc.want)
})
}
t.Run("too-long", func(t *testing.T) {
srv := newTestServer(newMemoryBackend())
longURI := "https://a.test/" + strings.Repeat("x", 512)
bodyBytes, _ := json.Marshal(map[string][]string{"redirect_uris": {longURI}})
req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/register", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.Register(rec, req)
assertRegisterError(t, rec, http.StatusBadRequest, "invalid_redirect_uri", "Each redirect URI must be at most 512 characters.")
})
}
func TestRegisterUnsupportedAuthMethod(t *testing.T) {
srv := newTestServer(newMemoryBackend())
body := `{"redirect_uris":["https://client.example.test/callback"],"token_endpoint_auth_method":"bogus"}`
req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/register", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.Register(rec, req)
assertRegisterError(t, rec, http.StatusBadRequest, "invalid_client_metadata", "token_endpoint_auth_method is not supported")
}
func TestRegisterUnsupportedGrantType(t *testing.T) {
srv := newTestServer(newMemoryBackend())
body := `{"redirect_uris":["https://client.example.test/callback"],"grant_types":["client_credentials"]}`
req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/register", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.Register(rec, req)
assertRegisterError(t, rec, http.StatusBadRequest, "invalid_client_metadata", "grant_types contains an unsupported value")
}
func TestRegisterGrantTypesMustIncludeAuthorizationCode(t *testing.T) {
srv := newTestServer(newMemoryBackend())
body := `{"redirect_uris":["https://client.example.test/callback"],"grant_types":["refresh_token"]}`
req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/register", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.Register(rec, req)
assertRegisterError(t, rec, http.StatusBadRequest, "invalid_client_metadata", "grant_types must include authorization_code")
}
func TestRegisterUnsupportedResponseType(t *testing.T) {
srv := newTestServer(newMemoryBackend())
body := `{"redirect_uris":["https://client.example.test/callback"],"response_types":["token"]}`
req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/register", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.Register(rec, req)
assertRegisterError(t, rec, http.StatusBadRequest, "invalid_client_metadata", "response_types must be code")
}
func TestRegisterControlCharacterNameCleaning(t *testing.T) {
srv := newTestServer(newMemoryBackend())
dirty := "Evil\x00Name\x1FWith\x7FControls"
bodyBytes, _ := json.Marshal(map[string]any{
"redirect_uris": []string{"https://client.example.test/callback"},
"client_name": dirty,
})
req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/register", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.Register(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
name, _ := got["client_name"].(string)
if strings.ContainsAny(name, "\x00\x1f\x7f") {
t.Fatalf("client_name %q still contains control characters", name)
}
if name != "EvilNameWithControls" {
t.Fatalf("client_name = %q, want stripped \"EvilNameWithControls\"", name)
}
}
func TestRegisterCapReached(t *testing.T) {
backend := newMemoryBackend()
srv := newTestServer(backend)
srv.opts.DCRClientCap = 1
body := `{"redirect_uris":["https://client.example.test/callback"]}`
req1 := httptest.NewRequest(http.MethodPost, "/oauth/mcp/register", strings.NewReader(body))
req1.Header.Set("Content-Type", "application/json")
rec1 := httptest.NewRecorder()
srv.Register(rec1, req1)
if rec1.Code != http.StatusCreated {
t.Fatalf("first registration status = %d, body=%s", rec1.Code, rec1.Body.String())
}
req2 := httptest.NewRequest(http.MethodPost, "/oauth/mcp/register", strings.NewReader(body))
req2.Header.Set("Content-Type", "application/json")
rec2 := httptest.NewRecorder()
srv.Register(rec2, req2)
assertRegisterError(t, rec2, http.StatusBadRequest, "invalid_client_metadata", "Registration temporarily unavailable")
}
func TestRegisterSweepsStaleUnconsentedButKeepsArtisanClients(t *testing.T) {
backend := newMemoryBackend()
srv := newTestServer(backend)
staleIP := "203.0.113.9"
backend.clients = append(backend.clients,
&ClientRecord{ClientID: "stale-dynamic", RegistrationIP: &staleIP, CreatedAt: time.Now().Add(-48 * time.Hour)},
&ClientRecord{ClientID: "artisan-client", RegistrationIP: nil, CreatedAt: time.Now().Add(-48 * time.Hour)},
)
body := `{"redirect_uris":["https://client.example.test/callback"]}`
req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/register", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.Register(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
backend.mu.Lock()
defer backend.mu.Unlock()
var ids []string
for _, c := range backend.clients {
ids = append(ids, c.ClientID)
}
if containsString(ids, "stale-dynamic") {
t.Fatalf("stale unconsented dynamic client survived sweep: %v", ids)
}
if !containsString(ids, "artisan-client") {
t.Fatalf("artisan client (nil RegistrationIP) was swept: %v", ids)
}
}
func containsString(ss []string, v string) bool {
for _, s := range ss {
if s == v {
return true
}
}
return false
}
func assertRegisterError(t *testing.T, rec *httptest.ResponseRecorder, status int, code, description string) {
t.Helper()
if rec.Code != status {
t.Fatalf("status = %d, want %d (body=%s)", rec.Code, status, rec.Body.String())
}
var got map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode error body: %v (body=%s)", err, rec.Body.String())
}
if got["error"] != code {
t.Fatalf("error = %v, want %q", got["error"], code)
}
if got["error_description"] != description {
t.Fatalf("error_description = %v, want %q", got["error_description"], description)
}
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
t.Fatalf("Cache-Control = %q, want \"no-store\"", cc)
}
}

View File

@@ -14,6 +14,7 @@ import (
"bytes"
"encoding/json"
"net/http"
"time"
)
// Options configures a Server's advertised endpoints and metadata values.
@@ -42,6 +43,20 @@ type Options struct {
// 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
}
// DefaultOptions returns PHP-parity defaults for every metadata option
@@ -52,18 +67,41 @@ func DefaultOptions() Options {
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,
}
}
// 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
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.
// 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}
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

139
wristband/stores.go Normal file
View File

@@ -0,0 +1,139 @@
package wristband
import (
"context"
"errors"
"time"
)
// ErrClientCapReached is returned by ClientStore.CreateWithCap when the
// unrevoked client count is already at or above the configured cap
// (D-03/D-21, T-08-DCR-FLOOD). Register translates it into the exact PHP
// invalid_client_metadata "Registration temporarily unavailable" body.
var ErrClientCapReached = errors.New("wristband: dynamic client registration cap reached")
// ClientRecord is the app-agnostic persisted shape of an OAuth client row.
// fonoteka's GORM adapter (classes/auth/oauth_store.go) converts to/from
// its models.OAuthClient; wristband never imports GORM or fonoteka.
type ClientRecord struct {
ID uint
ClientID string
ClientSecretHash *string // nil for public (auth method "none") clients
ClientName string
RedirectURIs []string
GrantTypes []string
TokenEndpointAuthMethod string
RegistrationIP *string // nil for artisan-issued clients (D-19); never swept
ConsentedAt *time.Time
RevokedAt *time.Time
ScopeCeiling []string // nil/empty means no ceiling
CreatedAt time.Time
}
// AuthCodeRecord is the app-agnostic persisted shape of a pending or issued
// authorization row. Its full lifecycle (issue, exchange, replay) belongs to
// a later Phase 8 plan (08-03/08-04); this plan only needs the shape and the
// row-lock read so the Tx bundle is complete for T-08-CODE-REPLAY.
type AuthCodeRecord struct {
ID uint
RequestID *string // non-nil while pending; nulled once a code is issued
CodeHash *string // nil while pending; set once a code is issued
ClientID string
UserID *uint // nil until consent
RedirectURI string
Scopes []string
CollectionIDs []uint
CodeChallenge string
CodeChallengeMethod string
Resource *string
State *string
ExpiresAt time.Time
UsedAt *time.Time
OfflineAccess bool
}
// RefreshTokenRecord is the app-agnostic persisted shape of a refresh-token
// lineage row. Rotation/replay semantics belong to a later plan (08-04);
// this plan only needs the shape and the row-lock read for T-08-REFRESH-REPLAY.
type RefreshTokenRecord struct {
ID uint
TokenHash string
APITokenID *uint
ClientID string
UserID uint
Scopes []string
CollectionIDs []uint
ExpiresAt time.Time
RevokedAt *time.Time
RotatedToID *uint
OfflineAccess bool
}
// IssuedToken is what an AccessTokenIssuer mints: the one-time raw secret
// plus the persisted row id (for later Revoke calls).
type IssuedToken struct {
ID uint
Secret string
}
// ClientStore persists OAuthClient rows. This plan (08-02) exercises
// ByClientID, CreateWithCap and SweepUnconsented through Register; Revoke
// belongs to a later connected-apps plan.
type ClientStore interface {
ByClientID(ctx context.Context, clientID string) (*ClientRecord, error)
// CreateWithCap creates rec only when the unrevoked client count is
// below cap, atomically with the count check (T-08-DCR-FLOOD). It
// returns ErrClientCapReached, leaving no row created, when the cap is
// already reached. On success it fills rec.ID and rec.CreatedAt.
CreateWithCap(ctx context.Context, rec *ClientRecord, cap int) error
// SweepUnconsented deletes dynamically-registered (non-nil
// RegistrationIP), still-unconsented clients created before olderThan.
// Artisan-issued clients (nil RegistrationIP) are never swept (D-19).
SweepUnconsented(ctx context.Context, olderThan time.Time) error
}
// AuthCodeStore persists pending/issued authorization rows. ByCodeHashForUpdate
// is the row-lock read a later plan's code-exchange/replay-kill logic needs
// (T-08-CODE-REPLAY); it ships now so the Tx bundle does not change shape
// later.
type AuthCodeStore interface {
CreatePending(ctx context.Context, rec *AuthCodeRecord) error
ByRequestID(ctx context.Context, requestID string) (*AuthCodeRecord, error)
ByCodeHashForUpdate(ctx context.Context, codeHash string) (*AuthCodeRecord, error)
MarkIssued(ctx context.Context, id uint, codeHash string, userID uint) error
MarkUsed(ctx context.Context, id uint) error
}
// RefreshTokenStore persists refresh-token lineage rows. ByTokenHashForUpdate
// is the row-lock read a later plan's rotation/replay-kill logic needs
// (T-08-REFRESH-REPLAY).
type RefreshTokenStore interface {
Create(ctx context.Context, rec *RefreshTokenRecord) error
ByTokenHashForUpdate(ctx context.Context, tokenHash string) (*RefreshTokenRecord, error)
RevokeLineage(ctx context.Context, startID uint) error
}
// AccessTokenIssuer mints/revokes the app's ordinary personal access token
// (fonoteka: an inv_ token via ApiTokenManager) and stamps the owning OAuth
// client id.
type AccessTokenIssuer interface {
Mint(ctx context.Context, userID uint, name string, scopes []string, expiresAt time.Time, collectionIDs []uint, clientID string) (IssuedToken, error)
Revoke(ctx context.Context, tokenID uint) error
}
// Tx bundles every store/issuer onto one transaction-scoped handle so
// sweep+cap+create (this plan) and later code-exchange/refresh-rotation
// cannot straddle two transactions (08-RESEARCH.md Pattern 1).
type Tx interface {
ClientStore
AuthCodeStore
RefreshTokenStore
AccessTokenIssuer
}
// Backend opens one transaction-scoped Tx per call. The GORM adapter lives
// in fonoteka.go's classes/auth package (D-07): wristband never imports
// gorm.io/gorm or a fonoteka model.
type Backend interface {
WithinTx(ctx context.Context, fn func(Tx) error) error
}