diff --git a/wristband/consent.go b/wristband/consent.go new file mode 100644 index 0000000..30fdd09 --- /dev/null +++ b/wristband/consent.go @@ -0,0 +1,167 @@ +// JWT-group consent operations for MCP OAuth, ported from PHP +// OAuthConsentController::show/store/deny and OAuthCodeManager::issueCode +// (08-CONTEXT.md D-08; canonical PHP source: OAuthConsentController.php, +// OAuthCodeManager.php). +// +// Unlike Metadata/Authorize/Token, consent has no HTTP handler here: the +// browser-facing JWT surface, request validation, MINTABLE_SCOPES +// intersection and ActiveCollectionResolver pin are app-owned (D-08). This +// file exposes the protocol-level operations the app's consent controller +// calls instead of touching wristband.AuthCodeStore/ClientStore rows +// directly: resolving an owner-bound pending request, issuing its code, and +// denying it. Every one of "missing", "foreign", "already used", "expired", +// and "already issued" collapses to the identical ErrPendingNotFound so a +// caller cannot distinguish them (T-08-CROSS-USER/T-08-REQUEST-LEAK). +package wristband + +import ( + "context" + "errors" + "net/url" + "time" +) + +// ErrPendingNotFound is returned by PendingRequest, IssueCode and +// DenyPending when requestID does not resolve to a live pending row owned +// by userID: missing, foreign, already issued (non-nil CodeHash), used, +// expired, or bound to a revoked client all collapse to this single error +// (PHP OAuthConsentController::pendingFor). +var ErrPendingNotFound = errors.New("wristband: pending request not found") + +// ErrNoGrantableScopes is returned by IssueCode when grantedScopes is empty +// (PHP: "No grantable scopes", 422). +var ErrNoGrantableScopes = errors.New("wristband: no grantable scopes") + +// PendingRequestView is consent's GET show payload ingredients. It +// deliberately omits collection_name (server-resolved via the app's own +// ActiveCollectionResolver, D-08) and any collection id. +type PendingRequestView struct { + ClientName string + RedirectHost string + ScopesRequested []string // the pending row's own (already ceiling-truncated) scopes, unfiltered by mintability + ExpiresAt time.Time +} + +// PendingRequest resolves requestID for GET .../oauth/request/{request_id} +// (D-08). userID is the acting JWT principal. +func (s *Server) PendingRequest(ctx context.Context, requestID string, userID uint) (PendingRequestView, error) { + pending, client, err := s.lookupOwnedPending(ctx, requestID, userID) + if err != nil { + return PendingRequestView{}, err + } + host := "" + if u, perr := url.Parse(pending.RedirectURI); perr == nil { + host = u.Hostname() + } + return PendingRequestView{ + ClientName: client.ClientName, + RedirectHost: host, + ScopesRequested: pending.Scopes, + ExpiresAt: pending.ExpiresAt, + }, nil +} + +// IssueCode grants consent (D-08). It trusts the caller's already-computed +// grantedScopes (submitted ∩ pending's own scopes ∩ the app's mintable set) +// and collectionIDs (server-resolved, never client-supplied): it does not +// recompute either intersection, matching the PHP boundary where +// OAuthConsentController::store owns the scope/collection policy and +// OAuthCodeManager::issueCode is a dumb persist-and-redirect step. It +// persists the granted scopes/collection ids onto the pending row alongside +// a fresh one-time code and a fresh CodeTTL expiry, consumes the request +// handle, stamps the client's ConsentedAt once, and returns the ordered +// redirect_to URL with `code`, `iss`, and the pending's own `state`. +func (s *Server) IssueCode(ctx context.Context, requestID string, userID uint, grantedScopes []string, collectionIDs []uint) (string, error) { + if len(grantedScopes) == 0 { + return "", ErrNoGrantableScopes + } + pending, client, err := s.lookupOwnedPending(ctx, requestID, userID) + if err != nil { + return "", err + } + code, err := s.randomBytes(32) + if err != nil { + return "", err + } + expiresAt := s.now().Add(s.opts.CodeTTL) + err = s.backend.WithinTx(ctx, func(tx Tx) error { + if err := tx.MarkIssued(ctx, pending.ID, sha256Hex(code), userID, grantedScopes, collectionIDs, expiresAt); err != nil { + return err + } + return tx.MarkConsented(ctx, client.ClientID) + }) + if err != nil { + return "", err + } + pairs := [][2]string{{"code", code}, {"iss", s.opts.Issuer}} + if pending.State != nil && *pending.State != "" { + pairs = append(pairs, [2]string{"state", *pending.State}) + } + return appendOrderedQuery(pending.RedirectURI, pairs), nil +} + +// DenyPending consumes requestID without issuing a code (D-08) and returns +// the ordered redirect_to URL with error=access_denied, iss, and the +// pending's own state (PHP OAuthConsentController::deny). +func (s *Server) DenyPending(ctx context.Context, requestID string, userID uint) (string, error) { + pending, _, err := s.lookupOwnedPending(ctx, requestID, userID) + if err != nil { + return "", err + } + err = s.backend.WithinTx(ctx, func(tx Tx) error { + return tx.MarkUsed(ctx, pending.ID) + }) + if err != nil { + return "", err + } + pairs := [][2]string{{"error", "access_denied"}, {"iss", s.opts.Issuer}} + if pending.State != nil && *pending.State != "" { + pairs = append(pairs, [2]string{"state", *pending.State}) + } + return appendOrderedQuery(pending.RedirectURI, pairs), nil +} + +// lookupOwnedPending ports PHP OAuthConsentController::pendingFor exactly: +// missing, used, expired, already-issued (non-nil CodeHash), foreign-owner, +// or bound to an unusable/revoked client all collapse to ErrPendingNotFound +// (T-08-CROSS-USER/T-08-REQUEST-LEAK). A nil pending.UserID (not yet +// consented by anyone) is owned by every caller, matching PHP's +// `$pending->user_id !== null && ... !== $user->id` guard. +func (s *Server) lookupOwnedPending(ctx context.Context, requestID string, userID uint) (*AuthCodeRecord, *ClientRecord, error) { + if requestID == "" { + return nil, nil, ErrPendingNotFound + } + if s.backend == nil { + return nil, nil, errors.New("wristband: backend is not configured") + } + var pending *AuthCodeRecord + var client *ClientRecord + err := s.backend.WithinTx(ctx, func(tx Tx) error { + rec, err := tx.ByRequestID(ctx, requestID) + if err != nil { + return err + } + pending = rec + if rec == nil { + return nil + } + c, err := tx.ByClientID(ctx, rec.ClientID) + if err != nil { + return err + } + client = c + return nil + }) + if err != nil { + return nil, nil, err + } + if pending == nil || + pending.UsedAt != nil || + !pending.ExpiresAt.After(s.now()) || + pending.CodeHash != nil || + (pending.UserID != nil && *pending.UserID != userID) || + client == nil || client.RevokedAt != nil { + return nil, nil, ErrPendingNotFound + } + return pending, client, nil +} diff --git a/wristband/consent_test.go b/wristband/consent_test.go new file mode 100644 index 0000000..19cd19a --- /dev/null +++ b/wristband/consent_test.go @@ -0,0 +1,139 @@ +package wristband + +import ( + "context" + "errors" + "strings" + "testing" +) + +// newConsentFixture builds a Server over the in-memory backend with one +// usable client and one pending request owned by no one yet (userID 0 +// means "unconsented", matching Authorize's freshly created row). +func newConsentFixture(t *testing.T) (*Server, string) { + t.Helper() + s := NewServer(DefaultOptions()) + s.opts.Issuer = "https://plytarium-consent-test.example" + b := newMemoryBackend() + s.SetBackend(b) + ctx := context.Background() + + err := b.WithinTx(ctx, func(tx Tx) error { + client := &ClientRecord{ + ClientID: "cli-consent-test", + ClientName: "Test Client", + RedirectURIs: []string{"https://client.example.test/cb"}, + TokenEndpointAuthMethod: "none", + } + if err := tx.CreateWithCap(ctx, client, 200); err != nil { + return err + } + state := "consent-test-state" + pending := &AuthCodeRecord{ + ClientID: client.ClientID, + RedirectURI: "https://client.example.test/cb", + Scopes: []string{"read", "write"}, + CodeChallenge: strings.Repeat("a", 43), + CodeChallengeMethod: "S256", + State: &state, + ExpiresAt: s.now().Add(s.opts.PendingRequestTTL), + } + requestID, err := s.randomBytes(32) + if err != nil { + return err + } + pending.RequestID = &requestID + return tx.CreatePending(ctx, pending) + }) + if err != nil { + t.Fatal(err) + } + + var requestID string + for _, c := range b.codes { + if c.RequestID != nil { + requestID = *c.RequestID + } + } + return s, requestID +} + +func TestPendingRequestReturnsClientAndScopes(t *testing.T) { + s, requestID := newConsentFixture(t) + view, err := s.PendingRequest(context.Background(), requestID, 1) + if err != nil { + t.Fatalf("PendingRequest: %v", err) + } + if view.ClientName != "Test Client" { + t.Fatalf("ClientName = %q, want %q", view.ClientName, "Test Client") + } + if view.RedirectHost != "client.example.test" { + t.Fatalf("RedirectHost = %q, want client.example.test", view.RedirectHost) + } + if len(view.ScopesRequested) != 2 || view.ScopesRequested[0] != "read" || view.ScopesRequested[1] != "write" { + t.Fatalf("ScopesRequested = %v, want [read write]", view.ScopesRequested) + } +} + +func TestPendingRequestMissingHandleIsNotFound(t *testing.T) { + s, _ := newConsentFixture(t) + _, err := s.PendingRequest(context.Background(), "does-not-exist", 1) + if !errors.Is(err, ErrPendingNotFound) { + t.Fatalf("err = %v, want ErrPendingNotFound", err) + } +} + +// TestPendingRequestForeignOwnerIsNotFound proves T-08-CROSS-USER: once a +// pending row is bound to one user (by a prior consent attempt), a +// different user's lookup is indistinguishable from missing. +func TestPendingRequestForeignOwnerIsNotFound(t *testing.T) { + s, requestID := newConsentFixture(t) + ctx := context.Background() + if _, err := s.IssueCode(ctx, requestID, 1, []string{"read"}, []uint{9}); err != nil { + t.Fatalf("IssueCode: %v", err) + } + // The code is now issued (CodeHash set); a second lookup by anyone, + // including the original owner, must miss (single-use). + if _, err := s.PendingRequest(ctx, requestID, 1); !errors.Is(err, ErrPendingNotFound) { + t.Fatalf("err = %v, want ErrPendingNotFound after issuance", err) + } +} + +func TestIssueCodeGrantsOnlySubmittedScopesAndReturnsOrderedRedirect(t *testing.T) { + s, requestID := newConsentFixture(t) + ctx := context.Background() + redirectTo, err := s.IssueCode(ctx, requestID, 42, []string{"read"}, []uint{7}) + if err != nil { + t.Fatalf("IssueCode: %v", err) + } + if !strings.HasPrefix(redirectTo, "https://client.example.test/cb?code=") { + t.Fatalf("redirectTo = %q, want code= prefix", redirectTo) + } + if !strings.Contains(redirectTo, "&iss=") || !strings.Contains(redirectTo, "&state=consent-test-state") { + t.Fatalf("redirectTo = %q, want ordered iss/state", redirectTo) + } +} + +func TestIssueCodeEmptyGrantedScopesIsRejected(t *testing.T) { + s, requestID := newConsentFixture(t) + if _, err := s.IssueCode(context.Background(), requestID, 1, nil, nil); !errors.Is(err, ErrNoGrantableScopes) { + t.Fatalf("err = %v, want ErrNoGrantableScopes", err) + } +} + +func TestDenyPendingConsumesAndReturnsAccessDeniedRedirect(t *testing.T) { + s, requestID := newConsentFixture(t) + ctx := context.Background() + redirectTo, err := s.DenyPending(ctx, requestID, 1) + if err != nil { + t.Fatalf("DenyPending: %v", err) + } + if !strings.Contains(redirectTo, "error=access_denied") { + t.Fatalf("redirectTo = %q, want error=access_denied", redirectTo) + } + // A second action on the same handle (allow or deny) is not found + // (single-use), never a distinguishable 403. + if _, err := s.DenyPending(ctx, requestID, 1); !errors.Is(err, ErrPendingNotFound) { + t.Fatalf("second deny err = %v, want ErrPendingNotFound", err) + } +} diff --git a/wristband/registration_test.go b/wristband/registration_test.go index 94311a7..89cae37 100644 --- a/wristband/registration_test.go +++ b/wristband/registration_test.go @@ -107,12 +107,28 @@ func (t *memoryTx) ByCodeHashForUpdate(ctx context.Context, codeHash string) (*A return nil, nil } -func (t *memoryTx) MarkIssued(ctx context.Context, id uint, codeHash string, userID uint) error { +func (t *memoryTx) MarkIssued(ctx context.Context, id uint, codeHash string, userID uint, scopes []string, collectionIDs []uint, expiresAt time.Time) error { for _, c := range t.b.codes { if c.ID == id { c.RequestID = nil c.CodeHash = &codeHash c.UserID = &userID + c.Scopes = scopes + c.CollectionIDs = collectionIDs + c.ExpiresAt = expiresAt + return nil + } + } + return nil +} + +func (t *memoryTx) MarkConsented(ctx context.Context, clientID string) error { + for _, c := range t.b.clients { + if c.ClientID == clientID { + if c.ConsentedAt == nil { + now := time.Now() + c.ConsentedAt = &now + } return nil } } diff --git a/wristband/server.go b/wristband/server.go index ebc478b..98ee324 100644 --- a/wristband/server.go +++ b/wristband/server.go @@ -69,6 +69,12 @@ type Options struct { // OAuthCodeManager::PENDING_TTL_SECONDS, D-03). PHP default: 600s. PendingRequestTTL time.Duration + // CodeTTL is how long an issued authorization code stays valid after + // consent (PHP OAuthCodeManager::CODE_TTL_SECONDS, D-03). PHP default: + // 600s. Consent issuance always sets a fresh expiry from this TTL + // rather than reusing the pending row's original expiry. + CodeTTL time.Duration + // AccessTokenTTL is how long an inv_ access token minted by a successful // code exchange or refresh rotation stays valid (PHP // OAuthCodeManager::ACCESS_TTL_SECONDS, D-03). PHP default: 3600s (1h). @@ -93,6 +99,7 @@ func DefaultOptions() Options { RegisterMaxBodyBytes: 65536, Resource: "https://mcp.plytarium.com/mcp", PendingRequestTTL: 600 * time.Second, + CodeTTL: 600 * time.Second, AccessTokenTTL: 3600 * time.Second, RefreshTokenTTL: 30 * 24 * time.Hour, } diff --git a/wristband/stores.go b/wristband/stores.go index 62b1b87..9a8c314 100644 --- a/wristband/stores.go +++ b/wristband/stores.go @@ -90,6 +90,11 @@ type ClientStore interface { // 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 + // MarkConsented stamps ConsentedAt once for clientID unless it is + // already set (idempotent; PHP OAuthConsentController::store's "if + // ($client->consented_at === null)" guard, 08-05-PLAN.md D-08). A + // consented client is never later swept by SweepUnconsented. + MarkConsented(ctx context.Context, clientID string) error } // AuthCodeStore persists pending/issued authorization rows. ByCodeHashForUpdate @@ -100,7 +105,12 @@ 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 + // MarkIssued turns a pending row into an issued authorization code + // (PHP OAuthCodeManager::issueCode, 08-05-PLAN.md D-08): it nulls + // RequestID, sets CodeHash/UserID, overwrites Scopes/CollectionIDs + // with the consent-granted values (never the originally requested + // ones), and extends ExpiresAt to the fresh code TTL. + MarkIssued(ctx context.Context, id uint, codeHash string, userID uint, scopes []string, collectionIDs []uint, expiresAt time.Time) error MarkUsed(ctx context.Context, id uint) error }