Files
summercms/wristband/stores.go
Jakub Zych a1fa9c6f44 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.
2026-09-23 20:59:24 +02:00

150 lines
6.3 KiB
Go

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