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
- Variables
- type Config
- type Option
- type Store
- func (s *Store) Close() error
- func (s *Store) ConsumeAuthorizationCode(ctx context.Context, hash string) (*oauth2server.AuthorizationCode, error)
- func (s *Store) ConsumeRefreshToken(ctx context.Context, hash string) (*oauth2server.RefreshToken, error)
- func (s *Store) CreateAccessToken(ctx context.Context, token *oauth2server.AccessToken) error
- func (s *Store) CreateAuthorizationCode(ctx context.Context, code *oauth2server.AuthorizationCode) error
- func (s *Store) CreateClient(ctx context.Context, client *oauth2server.Client) error
- func (s *Store) CreateRefreshToken(ctx context.Context, token *oauth2server.RefreshToken) error
- func (s *Store) DeleteClient(ctx context.Context, clientID string) error
- func (s *Store) GetAccessToken(ctx context.Context, hash string) (*oauth2server.AccessToken, error)
- func (s *Store) GetClient(ctx context.Context, clientID string) (*oauth2server.Client, error)
- func (s *Store) GetRefreshToken(ctx context.Context, hash string) (*oauth2server.RefreshToken, error)
- func (s *Store) RevokeAccessToken(ctx context.Context, hash string) error
- func (s *Store) RevokeFamily(ctx context.Context, familyID string) (int64, error)
- func (s *Store) RevokeRefreshToken(ctx context.Context, hash string) error
- func (s *Store) Sweep(ctx context.Context, now time.Time) (int64, error)
Constants ¶
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 ¶
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 ¶
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 ¶
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 ¶
WithLogger attaches a logger. An absent logger logs nowhere.
func WithMetricsProvider ¶
WithMetricsProvider attaches a metrics provider for the sweeper's counters. An absent one records nothing.
func WithSweeper ¶
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 ¶
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 ¶
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) 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 ¶
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 ¶
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) GetRefreshToken ¶
func (s *Store) GetRefreshToken(ctx context.Context, hash string) (*oauth2server.RefreshToken, error)
GetRefreshToken reads a refresh token without consuming it.
func (*Store) RevokeAccessToken ¶
RevokeAccessToken marks one access token revoked.
func (*Store) RevokeFamily ¶
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 ¶
RevokeRefreshToken marks one refresh token revoked.
func (*Store) Sweep ¶
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.
Source Files
¶
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. |