From c026b83f41bd897d2949dcda3fbc9ecbb0d04722 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 23 Sep 2026 19:37:03 +0200 Subject: [PATCH] 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) --- wristband/crypto.go | 41 +++ wristband/register.go | 13 + wristband/registration_test.go | 532 +++++++++++++++++++++++++++++++++ wristband/server.go | 44 ++- wristband/stores.go | 139 +++++++++ 5 files changed, 766 insertions(+), 3 deletions(-) create mode 100644 wristband/crypto.go create mode 100644 wristband/register.go create mode 100644 wristband/registration_test.go create mode 100644 wristband/stores.go diff --git a/wristband/crypto.go b/wristband/crypto.go new file mode 100644 index 0000000..403c235 --- /dev/null +++ b/wristband/crypto.go @@ -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[:]) +} diff --git a/wristband/register.go b/wristband/register.go new file mode 100644 index 0000000..38f2e47 --- /dev/null +++ b/wristband/register.go @@ -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) +} diff --git a/wristband/registration_test.go b/wristband/registration_test.go new file mode 100644 index 0000000..94311a7 --- /dev/null +++ b/wristband/registration_test.go @@ -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) + } +} diff --git a/wristband/server.go b/wristband/server.go index 4d2e241..3d53377 100644 --- a/wristband/server.go +++ b/wristband/server.go @@ -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 diff --git a/wristband/stores.go b/wristband/stores.go new file mode 100644 index 0000000..62b1b87 --- /dev/null +++ b/wristband/stores.go @@ -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 +}