database

package
v10.1.0 Latest Latest
Warning

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

Go to latest
Published: Aug 15, 2026 License: AGPL-3.0 Imports: 18 Imported by: 0

Documentation

Overview

Package database keeps an authorization server's state in SQL tables.

store, _ := database.NewStore(&database.Config{}, db,
	database.WithSweeper(ctx, oauth2server.DefaultSweepInterval))

srv, _ := oauth2server.NewServer("https://auth.example", store, authenticator)

This is the implementation a deployment wants. The alternative — four maps behind an RWMutex — is what a consumer assembling an authorization server from the reference examples writes, and it works perfectly on one replica.

What durability actually buys here

Three specific things, and none of them is "data survives a restart" in the abstract.

An authorization code is written by whichever replica served /authorize and read by whichever replica serves /token, a few hundred milliseconds later. With per-process state those are the same replica or the login fails — so a fleet behind a load balancer fails logins in proportion to how evenly the balancer spreads them, which presents as a flaky login rather than as a missing dependency.

A registered client outlives a deploy. Under RFC 7591 dynamic registration every client is a registered client, so per-process state means a deploy invalidates the entire client population at once.

A revocation is enforceable. Access tokens here are opaque and checked against this store on every resource-server request, so a sign-out ends a session now rather than at the end of the token's lifetime.

Four tables

<prefix>oauth2_clients               registrations, with an optional expiry
<prefix>oauth2_authorization_codes   one row per issued code, spent once
<prefix>oauth2_access_tokens         opaque tokens, by digest
<prefix>oauth2_refresh_tokens        rotating tokens, grouped by family

The DDL ships in database/migrations, rendered for postgres, mysql, and sqlite; hand SQL to database/migrate's WithGeneratedMigration and the tables are created by the consumer's own migration run.

Every credential table keys on a hex SHA-256 digest, never on the credential. A dump of this database therefore contains nothing that can be redeemed — a property the map-backed store gets for free by dying with the process, and the one that most obviously stops being free the moment the store is a table.

The two statements that carry the design

Consuming an authorization code is a single guarded UPDATE:

UPDATE ...codes SET redeemed_at = $now
 WHERE hash = $hash AND redeemed_at IS NULL AND expires_at > $now

Both predicates are load-bearing. `redeemed_at IS NULL` is what makes two concurrent redemptions of one code resolve to exactly one winner; `expires_at > $now` closes the window in which a code expires between a read and the write that follows it. A store that checked either of those in Go would have both races, and neither would show up in a test that redeems one code at a time.

Rotating a refresh token is the same statement with `revoked_at IS NULL` added, which is what keeps a revoked token from reading as a replay — it was never exchanged, so reporting reuse would revoke a family every time somebody signs out and their client retries.

A SELECT follows each UPDATE inside the same transaction, but only to explain what the UPDATE already decided: whether zero rows affected meant absent, replayed, or expired.

Nullable timestamps

expires_at on a client, redeemed_at on a code or refresh token, and revoked_at on either token are all nullable, and the NULL is the point. It distinguishes "never expires" from "expired at the zero time", and lets every predicate above be an `IS NULL` rather than a comparison against a magic date.

Sweeping

Nothing reclaims these rows on its own. WithSweeper runs one, or a scheduler calls Sweep — which is the better answer for a fleet, since it is one sweep rather than one per replica. Without either, the authorization code table grows by one row per login attempt forever, and the client table grows by one row per anonymous registration.

A revoked token that has not yet expired is deliberately kept: a resource server holding one is entitled to be told "no" rather than to have its request read as carrying a token nobody ever issued.

Conformance

This store and the memory one are held to the same suite, authentication/oauth2server/oauth2servertest, including the concurrent redemption and expiry-between-read-and-write cases. That is the only thing that says a guarded UPDATE and a mutex mean the same thing.

Index

Constants

View Source
const DefaultTablePrefix = ""

DefaultTablePrefix is the namespace the tables carry when none is configured, which is none — rendering plain "oauth2_clients" and friends.

The oauth2 segment is the schema's, not the caller's: a table always says which package created it. A namespace must not end in '_'; database/ddl supplies the separator.

Variables

View Source
var ErrNilClient = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil oauth2 database client")

ErrNilClient indicates NewStore was called without a database client. It wraps errors.ErrNilInputParameter, so a caller may check either.

Functions

This section is empty.

Types

type Config

type Config struct {

	// TablePrefix is the namespace prepended to this package's four table
	// names. Empty renders the schema's own names; set it to share a database
	// between applications, which renders e.g. ddb_oauth2_clients. It must not
	// end in '_' — the separator is supplied for you.
	//
	// The longest identifier here is 49 bytes before a prefix is applied, so
	// there is less room than in most schemas in this module; a prefix that
	// works elsewhere can be rejected here.
	TablePrefix string `env:"TABLE_PREFIX" json:"tablePrefix,omitempty" yaml:"tablePrefix,omitempty"`
	// contains filtered or unexported fields
}

Config configures a Store.

It carries no dialect. The dialect comes from the database.Client, which is the only place it can come from and be right: a configured dialect that disagrees with the client it is paired with produces syntactically valid SQL the server rejects at runtime.

func (*Config) ValidateWithContext

func (cfg *Config) ValidateWithContext(ctx context.Context) error

ValidateWithContext validates a Config struct.

The prefix is vetted against the schema rather than against a pattern, because a prefix that is a legal identifier on its own can still push an index name past what the supported engines accept — and that failure would otherwise surface as a migration that half ran, leaving two of four tables.

type Option

type Option func(*options)

Option configures a Store at construction.

func WithClock

func WithClock(c clock.Clock) Option

WithClock swaps the clock every deadline is evaluated against, and the one the sweeper ticks on.

It is this store's own clock rather than the database server's, deliberately. Both would work for expiry, but only this one can be replaced in a test, and only this one agrees with the clock the Server stamped the deadline from — which is what keeps "issued for fifteen minutes" and "expired" measuring the same fifteen minutes.

func WithLogger

func WithLogger(logger logging.Logger) Option

WithLogger attaches a logger. An absent logger logs nowhere.

func WithMetricsProvider

func WithMetricsProvider(metricsProvider metrics.Provider) Option

WithMetricsProvider attaches a metrics provider for the sweeper's counters. An absent one records nothing.

func WithSweeper

func WithSweeper(ctx context.Context, interval time.Duration) Option

WithSweeper starts a background sweep that removes dead records every interval, until ctx is done.

Unlike a cache, a table does not reclaim its own expired rows, and this schema has four of them — one of which an unauthenticated caller can write to. Running a sweep is not optional in any long-lived deployment; what is optional is running it here rather than from a scheduler that calls Sweep, which is the better answer for a fleet: one sweeper, not one per replica.

A nil context or a non-positive interval starts nothing.

func WithTracerProvider

func WithTracerProvider(tracerProvider tracing.Provider) Option

WithTracerProvider attaches a tracer provider. An absent one traces nowhere.

type Store

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

Store keeps every authorization server record in SQL tables.

This is the implementation the package exists for. The four maps behind an RWMutex that every consumer writes from the reference examples work perfectly until there are two replicas, at which point the authorization code is issued by one and redeemed at the other and the login fails — intermittently, in proportion to how well the load balancer spreads traffic, which is the hardest possible way to notice.

func NewStore

func NewStore(cfg *Config, db database.Client, opts ...Option) (*Store, error)

NewStore builds a Store over a database client.

Reads go through the write pool, deliberately. An authorization code is written by /authorize and read by /token milliseconds later, and replica lag turns that into a login that silently did not take — which the user answers by logging in again, producing another code that also appears not to exist. These rows are small, single-key, and short-lived; they are not the reads worth scaling out.

func (*Store) Close

func (s *Store) Close() error

Close releases the database client.

func (*Store) ConsumeAuthorizationCode

func (s *Store) ConsumeAuthorizationCode(ctx context.Context, hash string) (*oauth2server.AuthorizationCode, error)

ConsumeAuthorizationCode marks a code redeemed and returns it.

One guarded UPDATE decides it, and the SELECT that follows only explains what the UPDATE already settled. That ordering is the whole reason this store can be swapped for the map-backed one: the check and the mark are a single statement, so two requests carrying one code cannot both be told they won.

func (*Store) ConsumeRefreshToken

func (s *Store) ConsumeRefreshToken(ctx context.Context, hash string) (*oauth2server.RefreshToken, error)

ConsumeRefreshToken marks a refresh token redeemed and returns it. See ConsumeAuthorizationCode for why it is one guarded UPDATE.

func (*Store) CreateAccessToken

func (s *Store) CreateAccessToken(ctx context.Context, token *oauth2server.AccessToken) error

CreateAccessToken records an issued access token.

func (*Store) CreateAuthorizationCode

func (s *Store) CreateAuthorizationCode(ctx context.Context, code *oauth2server.AuthorizationCode) error

CreateAuthorizationCode records an issued code.

func (*Store) CreateClient

func (s *Store) CreateClient(ctx context.Context, client *oauth2server.Client) error

CreateClient records a registration.

func (*Store) CreateRefreshToken

func (s *Store) CreateRefreshToken(ctx context.Context, token *oauth2server.RefreshToken) error

CreateRefreshToken records an issued refresh token.

func (*Store) DeleteClient

func (s *Store) DeleteClient(ctx context.Context, clientID string) error

DeleteClient removes a registration. A registration that was already gone is not an error.

func (*Store) GetAccessToken

func (s *Store) GetAccessToken(ctx context.Context, hash string) (*oauth2server.AccessToken, error)

GetAccessToken reads an access token.

func (*Store) GetClient

func (s *Store) GetClient(ctx context.Context, clientID string) (*oauth2server.Client, error)

GetClient reads a registration.

func (*Store) GetRefreshToken

func (s *Store) GetRefreshToken(ctx context.Context, hash string) (*oauth2server.RefreshToken, error)

GetRefreshToken reads a refresh token without consuming it.

func (*Store) RevokeAccessToken

func (s *Store) RevokeAccessToken(ctx context.Context, hash string) error

RevokeAccessToken marks one access token revoked.

func (*Store) RevokeFamily

func (s *Store) RevokeFamily(ctx context.Context, familyID string) (int64, error)

RevokeFamily revokes every access and refresh token in a family.

Both statements run in one transaction. A partial revocation is the worst available outcome here: it is reached only by detecting a token reuse, and leaving half a family live means the response to a detected theft was to revoke some of what the thief holds.

func (*Store) RevokeRefreshToken

func (s *Store) RevokeRefreshToken(ctx context.Context, hash string) error

RevokeRefreshToken marks one refresh token revoked.

func (*Store) Sweep

func (s *Store) Sweep(ctx context.Context, now time.Time) (int64, error)

Sweep removes every row past its deadline.

One statement per table, in one transaction, no batching. The rows are small and each table's expires_at index makes the delete proportional to what is actually dead rather than to the table; a deployment that outgrows that wants a scheduled sweep with its own batching rather than a bigger one here.

Directories

Path Synopsis
Package migrations supplies the authorization server's DDL, rendered for a dialect and table prefix.
Package migrations supplies the authorization server's DDL, rendered for a dialect and table prefix.

Jump to

Keyboard shortcuts

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