feat(08-05): add wristband consent issue/deny operations
Server.PendingRequest/IssueCode/DenyPending port PHP OAuthConsentController::pendingFor/OAuthCodeManager::issueCode as app-agnostic protocol operations (08-CONTEXT.md D-08): every missing, foreign-owner, used, expired, or already-issued pending row collapses to the identical ErrPendingNotFound (T-08-CROSS-USER/T-08-REQUEST-LEAK). IssueCode trusts the caller's already-computed granted scopes/collection ids and returns the ordered redirect_to URL built through the existing RFC 3986 encoder. AuthCodeStore.MarkIssued gains scopes/collectionIDs/expiresAt parameters (PHP's issueCode overwrites all three, not just code_hash/user_id) and ClientStore gains MarkConsented, both required for D-08's consented_at stamping and server-derived grant persistence. Options gains CodeTTL (600s PHP-parity default) following the established Options-extension pattern.
This commit is contained in:
167
wristband/consent.go
Normal file
167
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
|
||||||
|
}
|
||||||
139
wristband/consent_test.go
Normal file
139
wristband/consent_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,12 +107,28 @@ func (t *memoryTx) ByCodeHashForUpdate(ctx context.Context, codeHash string) (*A
|
|||||||
return nil, nil
|
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 {
|
for _, c := range t.b.codes {
|
||||||
if c.ID == id {
|
if c.ID == id {
|
||||||
c.RequestID = nil
|
c.RequestID = nil
|
||||||
c.CodeHash = &codeHash
|
c.CodeHash = &codeHash
|
||||||
c.UserID = &userID
|
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
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ type Options struct {
|
|||||||
// OAuthCodeManager::PENDING_TTL_SECONDS, D-03). PHP default: 600s.
|
// OAuthCodeManager::PENDING_TTL_SECONDS, D-03). PHP default: 600s.
|
||||||
PendingRequestTTL time.Duration
|
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
|
// AccessTokenTTL is how long an inv_ access token minted by a successful
|
||||||
// code exchange or refresh rotation stays valid (PHP
|
// code exchange or refresh rotation stays valid (PHP
|
||||||
// OAuthCodeManager::ACCESS_TTL_SECONDS, D-03). PHP default: 3600s (1h).
|
// OAuthCodeManager::ACCESS_TTL_SECONDS, D-03). PHP default: 3600s (1h).
|
||||||
@@ -93,6 +99,7 @@ func DefaultOptions() Options {
|
|||||||
RegisterMaxBodyBytes: 65536,
|
RegisterMaxBodyBytes: 65536,
|
||||||
Resource: "https://mcp.plytarium.com/mcp",
|
Resource: "https://mcp.plytarium.com/mcp",
|
||||||
PendingRequestTTL: 600 * time.Second,
|
PendingRequestTTL: 600 * time.Second,
|
||||||
|
CodeTTL: 600 * time.Second,
|
||||||
AccessTokenTTL: 3600 * time.Second,
|
AccessTokenTTL: 3600 * time.Second,
|
||||||
RefreshTokenTTL: 30 * 24 * time.Hour,
|
RefreshTokenTTL: 30 * 24 * time.Hour,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ type ClientStore interface {
|
|||||||
// RegistrationIP), still-unconsented clients created before olderThan.
|
// RegistrationIP), still-unconsented clients created before olderThan.
|
||||||
// Artisan-issued clients (nil RegistrationIP) are never swept (D-19).
|
// Artisan-issued clients (nil RegistrationIP) are never swept (D-19).
|
||||||
SweepUnconsented(ctx context.Context, olderThan time.Time) error
|
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
|
// AuthCodeStore persists pending/issued authorization rows. ByCodeHashForUpdate
|
||||||
@@ -100,7 +105,12 @@ type AuthCodeStore interface {
|
|||||||
CreatePending(ctx context.Context, rec *AuthCodeRecord) error
|
CreatePending(ctx context.Context, rec *AuthCodeRecord) error
|
||||||
ByRequestID(ctx context.Context, requestID string) (*AuthCodeRecord, error)
|
ByRequestID(ctx context.Context, requestID string) (*AuthCodeRecord, error)
|
||||||
ByCodeHashForUpdate(ctx context.Context, codeHash 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
|
MarkUsed(ctx context.Context, id uint) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user