// 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 }