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:
41
wristband/crypto.go
Normal file
41
wristband/crypto.go
Normal 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
13
wristband/register.go
Normal 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)
|
||||
}
|
||||
532
wristband/registration_test.go
Normal file
532
wristband/registration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
139
wristband/stores.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user