challenge

package
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 2, 2026 License: MIT Imports: 24 Imported by: 0

Documentation

Overview

Package challenge owns single-use, model-bound recovery credentials. Typed password-reset and email-verification flows share one atomic persistence contract. No challenge produces an authentication proof.

Index

Constants

View Source
const MaxLifetime = 7 * 24 * time.Hour
View Source
const MaxPrune = 128

Variables

This section is empty.

Functions

func ValidateLifetime

func ValidateLifetime(lifetime time.Duration) error

ValidateLifetime requires a whole-microsecond lifetime between one millisecond and MaxLifetime.

Types

type Address

type Address struct {
	Namespace keyspace.Namespace
	Provider  auth.ProviderName
	Model     string
	Purpose   Kind
}

Address is an infrastructure boundary. Models retain their concrete key type in public flows; only persistence serializes identity.

func (Address) Key

func (a Address) Key() (string, error)

Key validates the address and returns its opaque persistence scope.

func (Address) SubjectKey

func (a Address) SubjectKey(identity model.Identity) (string, error)

SubjectKey returns the subject's opaque key within the validated challenge address.

func (Address) Validate

func (a Address) Validate() error

Validate checks the recovery purpose, namespace, provider and model.

type Backend

type Backend interface {
	// Issue locks the subject, prepares its binding and replaces the previous token in one
	// transaction without retries.
	Issue(context.Context, Address, model.Identity, Digest, time.Duration, func(context.Context, *database.Tx) (Binding, error)) (Record, error)
	// Consume locks and rechecks a live token, applies the action and removes the token in
	// the same transaction. An absent or expired token is omitted.
	Consume(context.Context, Address, Digest, func(context.Context, *database.Tx, Record) error) (value.Optional[Record], error)
	// Revoke removes the subject's challenge and reports whether one existed.
	Revoke(context.Context, Address, model.Identity) (bool, error)
	// Prune removes up to the supplied limit of expired challenges in the address.
	Prune(context.Context, Address, int) (uint64, error)
}

Backend owns a read-committed transaction for each operation, without retries. Issue locks the stable purpose/subject row, then invokes prepare exactly once in that transaction before replacing the previous token. Consume finds the hash, acquires the same subject lock, rechecks the current hash/expiry, invokes apply once, then removes the token in that SAME transaction. Before-commit errors/panic/Goexit/cancellation roll back model changes and consumption together. After-commit failures preserve a Committed database outcome; they cannot undo persistence. Never retry based only on the presence of an error. Callbacks must use only the supplied transaction for writes; no external I/O, detached work, nested challenge operations or additional pool acquisitions. Lock order is challenge subject, then application model. Stable subject rows remain after consumption. An uncertain commit returns an error and no result.

type Binding

type Binding struct {
	// contains filtered or unexported fields
}

Binding fingerprints the exact account state relevant to a link. Password reset includes the current email AND stored hash; verification includes the current email. Recovery flows also include a persisted Revision, so restoring an old address cannot revive old links. Bind alone compares current values. Bindings are not passwords or standalone authenticators. Keep them private.

func Bind

func Bind(parts ...secret.String) (Binding, error)

Bind fingerprints one to eight nonempty, bounded UTF-8 secret components into a model-state binding.

func BindRevision

func BindRevision[M any](revision Revision[M], parts ...secret.String) (Binding, error)

BindRevision binds current values AND their persisted history generation. A zero generation fails closed; consumers must initialize existing rows before enabling a recovery flow. The revision remains independent of link issuance.

func ParseBinding

func ParseBinding(text string) (Binding, error)

ParseBinding restores a binding from its encoded digest.

func (Binding) Equal

func (b Binding) Equal(other Binding) bool

Equal compares binding digests.

func (Binding) Format

func (Binding) Format(s fmt.State, _ rune)

Format writes the label "challenge binding" without exposing the value.

func (Binding) Hex

func (b Binding) Hex() string

Hex returns the binding digest's hexadecimal persistence representation.

func (Binding) IsZero

func (b Binding) IsZero() bool

IsZero reports whether the binding is uninitialized.

type Config

type Config struct {
	Namespace     keyspace.Namespace
	MaxConcurrent int
	Timeout       time.Duration
}

Config declares the challenge namespace and operation admission bounds.

func DefaultConfig

func DefaultConfig(namespace keyspace.Namespace) Config

DefaultConfig returns default challenge concurrency and timeout bounds for namespace.

func (Config) Validate

func (c Config) Validate() error

Validate checks the namespace, bounded positive concurrency and positive timeout.

type Digest

type Digest struct {
	// contains filtered or unexported fields
}

Digest is a nominal challenge hash, never interchangeable with access tokens.

func HashSecret

func HashSecret(raw secret.String) (Digest, error)

HashSecret validates and hashes a challenge secret, returning Unauthenticated for malformed input.

func ParseDigest

func ParseDigest(text string) (Digest, error)

ParseDigest restores a challenge digest from its hexadecimal representation.

func (Digest) Equal

func (d Digest) Equal(other Digest) bool

Equal compares challenge digests.

func (Digest) Format

func (Digest) Format(s fmt.State, _ rune)

Format writes the label "challenge digest" without exposing the value.

func (Digest) Hex

func (d Digest) Hex() string

Hex returns the digest's hexadecimal persistence representation.

func (Digest) IsZero

func (d Digest) IsZero() bool

IsZero reports whether the digest is uninitialized.

type EmailVerification

type EmailVerification struct{}

EmailVerification marks tokens and flows for email verification.

type Flow

type Flow[M model.Identifiable, K any, P Purpose] struct {
	// contains filtered or unexported fields
}

Flow binds a model and recovery purpose to a provider, store and lifetime.

func New

func New[M model.Identifiable, K any, P Purpose](store *Store, provider auth.Provider[M, K], binding Model[M, K], lifetime time.Duration) (*Flow[M, K, P], error)

New validates and binds the store, provider, model callbacks and challenge lifetime.

func (*Flow[M, K, P]) Consume

func (f *Flow[M, K, P]) Consume(ctx context.Context, token Token[M, P], apply func(context.Context, *database.Tx, M) (M, error)) (M, error)

Consume executes an identity-preserving model mutation inside the consumption transaction. Missing, expired, replaced and stale-state links return the same Unauthenticated error. No proof/session/token is issued by consuming a link.

func (*Flow[M, K, P]) Issue

func (f *Flow[M, K, P]) Issue(ctx context.Context, reference model.Reference[M, K]) (Issued[M, P], error)

Issue replaces the previous link for this model/provider/purpose. The reference selects a model; it does not authenticate the caller. Public request handlers must throttle, use a uniform response and deliver only to the stored address.

func (*Flow[M, K, P]) Prune

func (f *Flow[M, K, P]) Prune(ctx context.Context, limit int) (uint64, error)

Prune removes expired challenges within the flow's address, bounded by limit and MaxPrune.

func (*Flow[M, K, P]) Revoke

func (f *Flow[M, K, P]) Revoke(ctx context.Context, reference model.Reference[M, K]) (bool, error)

Revoke removes the reference's challenge after checking provider ownership.

func (*Flow[M, K, P]) Validate

func (f *Flow[M, K, P]) Validate() error

Validate checks that the flow and its store are configured.

type Issued

type Issued[M any, P Purpose] struct {
	// contains filtered or unexported fields
}

Issued keeps delivery information private to ordinary formatting and JSON. Use the locked Subject snapshot's email when constructing a delivery message. No built-in email delivery is performed here, nor is publication crash durable.

func (Issued[M, P]) ExpiresAt

func (i Issued[M, P]) ExpiresAt() temporal.DateTime

ExpiresAt returns the committed challenge deadline.

func (Issued[M, P]) Format

func (Issued[M, P]) Format(s fmt.State, _ rune)

Format writes the label "issued challenge" without exposing the value.

func (Issued[M, P]) Subject

func (i Issued[M, P]) Subject() M

Subject returns the model snapshot from issuance without cloning it.

func (Issued[M, P]) Token

func (i Issued[M, P]) Token() Token[M, P]

Token returns the secret challenge for explicit delivery.

type Issuer

type Issuer[M model.Identifiable, K any, P Purpose] interface {
	// Validate checks that the issuer is configured.
	Validate() error
	// Issue creates a model-bound challenge for the supplied reference.
	Issue(context.Context, model.Reference[M, K]) (Issued[M, P], error)
}

Issuer is the model/purpose contract implemented by Flow, passwordreset.Reset and emailverification.Verification. A requester borrows the configured issuer.

type Kind

type Kind string

Kind identifies a persisted recovery purpose.

const (
	ResetPassword Kind = "password-reset"
	VerifyEmail   Kind = "email-verification"
)

type Model

type Model[M model.Identifiable, K any] struct {
	Lock    func(context.Context, *database.Tx, K) (value.Optional[M], error)
	Binding func(M) (Binding, error)
	// Eligible optionally replaces the provider's login eligibility for this
	// flow, for example so email verification works before a provider that
	// requires verified addresses admits the account. Identity checks remain.
	// False rejects like a stale link. Omit it to reuse provider eligibility.
	Eligible func(context.Context, M) (bool, error)
}

Model owns only domain mapping. Lock must use a generated ForUpdate query on the supplied transaction and return an owned full model. Binding uses stored fields, never read getters or a stale model passed from an HTTP request.

type PasswordReset

type PasswordReset struct{}

PasswordReset marks tokens and flows for password reset.

type Purpose

type Purpose interface {
	PasswordReset | EmailVerification
}

Purpose is closed to the supported recovery operations. It is preserved in token and issuer types; a verification token cannot reset a password.

type Record

type Record struct {
	Address   Address
	Subject   model.Identity
	Hash      Digest
	Binding   Binding
	CreatedAt temporal.DateTime
	ExpiresAt temporal.DateTime
}

Record is a stored challenge's subject, binding, digest and lifetime.

func (Record) Format

func (Record) Format(s fmt.State, _ rune)

Format writes the label "stored challenge" without exposing the value.

func (Record) Live

func (r Record) Live(now time.Time) bool

Live reports whether now is at or after creation and strictly before expiry.

func (Record) Validate

func (r Record) Validate(address Address) error

Validate checks address ownership, subject, hashes, timestamps and lifetime.

type RequestCallbacks

type RequestCallbacks[M model.Identifiable, K, I any, P Purpose] struct {
	Lookup  func(context.Context, I) (value.Optional[model.Reference[M, K]], error)
	Deliver func(context.Context, Issued[M, P]) error
}

RequestCallbacks maps a canonical submitted identifier to a model reference and delivers ONLY to the committed issuance's stored subject address. Lookup is domain-specific; it grants no authority. Issue locks and checks the current model again. Deliver runs after commit, outside database locks, at most once. Both run in the requester's owned background dispatch, not in the caller's request. Deliver's result is not crash durable: an email/outbox adapter owns durable dispatch (for example enqueue a job carrying the issued link).

type Requests

type Requests[M model.Identifiable, K, I any, P Purpose] struct {
	// contains filtered or unexported fields
}

Requests owns public recovery-request orchestration and bounds all callbacks. It returns no token/model/existence flag. Request performs only the identifier's rate-limit admission synchronously, identical for existing and missing accounts, then hands lookup, issuance and delivery to a bounded background dispatch owned by the requester, so response latency does not depend on whether the account exists. Operational failures are logged safely. Close drains the owned dispatches at shutdown.

func NewRequests

func NewRequests[M model.Identifiable, K, I any, P Purpose](issuer Issuer[M, K, P], limiter ratelimit.Limiter[I], callbacks RequestCallbacks[M, K, I, P], logger *slog.Logger, config auth.Config) (*Requests[M, K, I, P], error)

NewRequests validates dependencies and creates bounded background recovery dispatch. The caller must close the requester to drain its work.

func (*Requests[M, K, I, P]) Close

func (r *Requests[M, K, I, P]) Close(ctx context.Context) error

Close stops accepting requests and waits for owned dispatches. When ctx ends first, remaining dispatches are canceled and Close returns ctx.Err(); a callback that ignores cancellation keeps its slot until it actually exits. It is idempotent. Register it with the application's shutdown hooks.

func (*Requests[M, K, I, P]) Request

func (r *Requests[M, K, I, P]) Request(ctx context.Context, identifier I) error

Request uses the same canonical identifier for quota and lookup. Validate and normalize it before this call. Quota denial is also an acknowledgement, without lookup or token replacement. Rate-authority failures, dispatch capacity (fault.Overloaded after a short bounded wait) and a closed requester are reported before any lookup, so they never depend on account existence. Ingress/IP limiting is separately required at the transport. There are no implicit retries, automatic login or plaintext recovery result. A lost/failed delivery can leave an undisclosed active link.

func (*Requests[M, K, I, P]) Validate

func (r *Requests[M, K, I, P]) Validate() error

Validate checks that the requester is configured.

func (*Requests[M, K, I, P]) Wait

func (r *Requests[M, K, I, P]) Wait(ctx context.Context) error

Wait blocks until every dispatch admitted so far has finished, or ctx ends. It does not stop new requests; tests and maintenance tools use it to observe completed delivery. A callback ignoring cancellation keeps Wait blocked.

type Revision

type Revision[M any] = model.ID[RevisionOwner[M]]

Revision is a persisted recovery-state generation, using the existing typed UUID codec. It is not a secret, account ID or authentication capability. Keep it on the model and replace it transactionally when protected state changes; never restore an earlier revision when restoring an earlier field value.

func NewRevision

func NewRevision[M any]() (Revision[M], error)

NewRevision creates a new model-specific revision identifier using the current time.

type RevisionOwner

type RevisionOwner[M any] struct {
	// contains filtered or unexported fields
}

RevisionOwner keeps a recovery-state generation separate from a model's primary key, while retaining its model owner. It is a type marker, not a stored model.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store borrows its backend and bounds callback capacity across every flow.

func NewStore

func NewStore(backend Backend, config Config) (*Store, error)

NewStore validates and borrows backend and creates bounded challenge-operation admission.

func (*Store) Validate

func (s *Store) Validate() error

Validate checks that the store is configured.

type Token

type Token[M any, P Purpose] struct {
	// contains filtered or unexported fields
}

Token retains its model and recovery purpose. Parsing validates syntax only; storage and current model state decide whether it may be consumed. Ordinary JSON/logging redacts it. Secret is the explicit delivery/transport boundary.

func ParseToken

func ParseToken[M any, P Purpose](raw secret.String) (Token[M, P], error)

ParseToken validates token syntax without verifying storage, expiry or current model state.

func (Token[M, P]) Format

func (Token[M, P]) Format(s fmt.State, _ rune)

Format writes the redaction marker without exposing the value.

func (Token[M, P]) JSONContract

func (Token[M, P]) JSONContract() contract.JSON[Token[M, P]]

JSONContract lets generated request DTOs retain both model and purpose. Its wire value is a string, with native parsing and redacted output. A contract's model/purpose name describes the Go declaration, not data claimed by a client.

func (Token[M, P]) LogValue

func (Token[M, P]) LogValue() slog.Value

LogValue returns the redaction marker without exposing the value.

func (Token[M, P]) MarshalJSON

func (Token[M, P]) MarshalJSON() ([]byte, error)

MarshalJSON encodes the redaction marker without exposing the value.

func (Token[M, P]) Secret

func (t Token[M, P]) Secret() secret.String

Secret explicitly exposes the secret wrapper for delivery or transport.

func (*Token[M, P]) UnmarshalJSON

func (t *Token[M, P]) UnmarshalJSON(data []byte) error

UnmarshalJSON accepts only a canonical opaque challenge. It resets a previous value on failure, never exposes input in errors, and does not establish model existence, purpose, validity or authority. Consumption checks those in storage.

func (Token[M, P]) Validate

func (t Token[M, P]) Validate() error

Validate checks secret syntax without verifying challenge validity.

Directories

Path Synopsis
Package postgres persists single-use recovery credentials through explicit migrations and generated queries.
Package postgres persists single-use recovery credentials through explicit migrations and generated queries.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL