refactor(10.2-01): nest framework packages under modules
- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
This commit is contained in:
167
modules/wristband/consent.go
Normal file
167
modules/wristband/consent.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user