- TestPhase8RedRegistration asserts the exact public-client DCR success contract and fails while Server.Register is a 501 stub - adds the Backend/Tx transaction-scoped store bundle (ClientStore, AuthCodeStore, RefreshTokenStore, AccessTokenIssuer) and wristband's own in-memory implementation for framework-level tests (D-07) - adds crypto.go's fixed-transform helpers (random base64url, sha256 hex, constant-time compare, S256) and Options/Server seams for the DCR lifetimes, cap, sweep age and 64 KiB body bound (D-03/D-21)
140 lines
5.6 KiB
Go
140 lines
5.6 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
|
|
}
|
|
|
|
// 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(ctx context.Context, id uint, codeHash string, userID uint) 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
|
|
}
|