Help us shape the future of Go! Take the 2026 Go Developer Survey

store

package
v0.7.0 Latest Latest
Warning

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

Go to latest
Published: Oct 19, 2026 License: MIT Imports: 4 Imported by: 0

Documentation

Overview

Package store defines the persistence ports that authit's service packages depend on. authit assumes no specific database: a host application supplies a concrete implementation of the interfaces in this package (Postgres, SQLite, in-memory, ...). The memstore package ships a reference in-memory implementation of every interface here, suitable for tests and small apps.

authit ships no DDL: your schema, naming and migrations are yours. It does ship a reference one. schema.sql in the repository root is a complete, non-binding table set for every interface here, annotated with the places where the column set is not guessable from the struct definitions -- and sqlbstore/example_test.go applies it and runs the real flows over it, so it is checked rather than merely asserted. Start there rather than reverse-engineering the columns one type at a time.

Index

Constants

This section is empty.

Variables

View Source
var ErrConflict = errors.New("authit/store: conflict")

ErrConflict is returned when a create would violate a uniqueness constraint (e.g. an email or slug that already exists).

View Source
var ErrNotFound = errors.New("authit/store: not found")

ErrNotFound is returned by lookup methods when no matching record exists.

Functions

func NormalizeEmail added in v0.4.0

func NormalizeEmail(email string) string

NormalizeEmail returns the canonical form of an email address: leading and trailing whitespace removed, and the whole address lower-cased.

This is part of the storage contract, not a convenience. Every service package normalises an address before it reaches a store -- both when writing it and when looking it up -- so an implementation can rely on the email column holding exactly this form, and on GetUserByEmail / GetSuperuserByEmail receiving exactly this form. That means a plain case-sensitive equality comparison is correct, and a store does NOT need a citext column, a functional index on lower(email), or a case-insensitive collation. If you add one anyway it is harmless.

Without it, "Alice@example.com" and "alice@example.com" are two accounts on a case-sensitive store and one account on a case-insensitive one -- authit's behaviour would be decided by your collation. Worse, the failed-login counter is keyed by email, so an attacker could have reset their own throttle just by varying the case they sent.

RFC 5321 does make the local part case-sensitive, so lower-casing all of it is formally lossy. It matches what mail providers actually do, and the alternative -- letting case create duplicate accounts -- is the worse failure. Anything more aggressive is deliberately absent: stripping dots or +tags is Gmail-specific behaviour that silently merges genuinely distinct addresses at other providers.

Upgrading

If you have rows written before authit normalised, an address stored with upper-case characters will no longer be found: the lookup key is now normalised and the stored value is not. Normalise the column once, and resolve any rows that collide, before deploying:

UPDATE users SET email = lower(btrim(email)) WHERE email <> lower(btrim(email));

func RunInTx added in v0.4.0

func RunInTx(ctx context.Context, tx TxRunner, fn func(context.Context) error) error

RunInTx runs fn inside tx, or directly when tx is nil.

It is the one-line shim authit's service packages use so that "no TxRunner configured" and "TxRunner configured" share a single code path. A host has no reason to call it, having its own TxRunner to hand.

Types

type Account added in v0.4.0

type Account struct {
	ID     string
	UserID string
	// Provider is the id of the provider that asserted this identity, e.g.
	// "google". It is the host's own name for it, matched exactly.
	Provider string
	// ProviderAccountID is the provider's stable subject identifier — not
	// the email address, which users change.
	ProviderAccountID string
	// Email is what the provider asserted at the last sign-in, normalised.
	Email string
	// EmailVerified records whether the provider claimed to have verified
	// the address. It is the provider's claim, not a fact.
	EmailVerified bool
	// AccessTokenEncrypted and RefreshTokenEncrypted hold the provider's
	// own OAuth tokens, if the host chose to keep them for calling the
	// provider's API later. They are ciphertext (AES-256-GCM, via the
	// crypto package) rather than plaintext, because a database that leaks
	// these leaks live access to the user's account at the provider.
	// Both are nil when token persistence is off, which is the default.
	AccessTokenEncrypted  []byte
	RefreshTokenEncrypted []byte
	// TokenExpiresAt is when AccessTokenEncrypted stops working.
	TokenExpiresAt *time.Time
	// Scopes are what the provider granted.
	Scopes    []string
	CreatedAt time.Time
	UpdatedAt time.Time
}

Account is an external identity linked to a User: the record that says "the Google subject 1234 is this authit user".

It is separate from User rather than columns on it because one user may hold several — a Google login and a GitHub login for the same person — and because an authit user may hold none at all, which is what a password-only account is.

The uniqueness that matters

(Provider, ProviderAccountID) must be UNIQUE. That pair is the only thing establishing which user a sign-in belongs to; without the constraint, a duplicate row is an account takeover waiting for a race.

Email is stored for display and for the linking decisions the oidc package makes, and is deliberately NOT unique: two providers can assert the same address, and one provider can assert an address that belongs to somebody else's authit account. Treating it as an identifier is the classic social-login vulnerability; see oidc.LinkingPolicy.

type AccountStore added in v0.4.0

type AccountStore interface {
	CreateAccount(ctx context.Context, a *Account) error
	GetAccount(ctx context.Context, id string) (*Account, error)
	// GetAccountByProvider looks up the link by the provider's own subject
	// identifier. This is the query every social sign-in makes, and the
	// (provider, provider_account_id) index it needs must be UNIQUE.
	GetAccountByProvider(ctx context.Context, provider, providerAccountID string) (*Account, error)
	ListAccountsByUser(ctx context.Context, userID string) ([]*Account, error)
	UpdateAccount(ctx context.Context, a *Account) error
	DeleteAccount(ctx context.Context, id string) error
}

AccountStore persists linked external identities.

type BoundDevice added in v0.7.0

type BoundDevice struct {
	ID        string
	UserID    string
	Name      string
	TokenHash string
	UserAgent string
	IPAddress string
	// ExpiresAt is nil for a device that stays signed in until revoked,
	// which is the pin package's default.
	ExpiresAt  *time.Time
	LastUsedAt *time.Time
	RevokedAt  *time.Time
	CreatedAt  time.Time
}

BoundDevice is a device a user has signed in on, holding a long-lived bearer token for it: the field phone that stays signed in through days without a connection. Only the token's hash is stored, hex(SHA-256(raw)), which is crypto.HashToken -- and also the format apps that rolled their own device tokens commonly used, so such rows import unchanged.

It is a separate thing from DeviceAuthorization, which is one RFC 8628 grant in flight for a CLI. Nothing here says how the device was bound; the pin package binds by PIN today, and another method could bind the same rows.

type BoundDeviceStore added in v0.7.0

type BoundDeviceStore interface {
	CreateBoundDevice(ctx context.Context, d *BoundDevice) error
	GetBoundDevice(ctx context.Context, id string) (*BoundDevice, error)
	// GetBoundDeviceByTokenHash returns revoked and expired devices too:
	// the service reads both columns itself. token_hash must be UNIQUE.
	GetBoundDeviceByTokenHash(ctx context.Context, hash string) (*BoundDevice, error)
	// ListBoundDevicesByUser lists every device of one user, revoked ones
	// included, oldest first.
	ListBoundDevicesByUser(ctx context.Context, userID string) ([]*BoundDevice, error)
	// TouchBoundDevice records a use at at, and reports ErrNotFound for a
	// device that is gone or revoked, so a use racing a revocation cannot
	// write over it.
	TouchBoundDevice(ctx context.Context, id string, at time.Time) error
	// RevokeBoundDevice revokes the device at at. Revoking a revoked device
	// keeps the first instant and is not an error; a missing one is
	// ErrNotFound.
	RevokeBoundDevice(ctx context.Context, id string, at time.Time) error
	// RevokeAllBoundDevices revokes every live device of userID and reports
	// how many it revoked.
	RevokeAllBoundDevices(ctx context.Context, userID string, at time.Time) (int, error)
}

BoundDeviceStore persists BoundDevice records.

type DeviceAuthorization

type DeviceAuthorization struct {
	ID              string
	DeviceCodeHash  string
	UserCode        string
	ClientID        string
	Scope           string
	Status          DeviceAuthorizationStatus
	UserID          *string // set once Status is Approved
	ExpiresAt       time.Time
	IntervalSeconds int
	LastPolledAt    *time.Time
	CreatedAt       time.Time
}

DeviceAuthorization is one in-flight RFC 8628 device-authorization-grant request. UserCode is stored as plaintext (not hashed): it is short and low-entropy by design — the security property comes from rate-limiting guesses at the approval endpoint (the host application's job), not from the code being a secret. DeviceCode, by contrast, IS a secret (the CLI's poll credential) and only its hash is persisted.

type DeviceAuthorizationStatus

type DeviceAuthorizationStatus string

DeviceAuthorizationStatus is the lifecycle state of a DeviceAuthorization.

const (
	DeviceAuthorizationPending  DeviceAuthorizationStatus = "pending"
	DeviceAuthorizationApproved DeviceAuthorizationStatus = "approved"
	DeviceAuthorizationDenied   DeviceAuthorizationStatus = "denied"
)

type DeviceAuthorizationStore

type DeviceAuthorizationStore interface {
	CreateDeviceAuthorization(ctx context.Context, d *DeviceAuthorization) error
	GetDeviceAuthorizationByDeviceCodeHash(ctx context.Context, hash string) (*DeviceAuthorization, error)
	GetDeviceAuthorizationByUserCode(ctx context.Context, userCode string) (*DeviceAuthorization, error)
	UpdateDeviceAuthorization(ctx context.Context, d *DeviceAuthorization) error
	DeleteDeviceAuthorization(ctx context.Context, id string) error
}

DeviceAuthorizationStore persists DeviceAuthorization records.

type EmailLoginKind added in v0.4.0

type EmailLoginKind string

EmailLoginKind distinguishes the two passwordless email flows, which differ in exactly one way that matters: how much entropy the credential carries.

const (
	// EmailLoginLink is a magic link: a full-entropy opaque token in a URL,
	// looked up directly by its hash.
	EmailLoginLink EmailLoginKind = "link"
	// EmailLoginCode is a short numeric code the user types. It is looked
	// up by email address rather than by hash, because six digits are not
	// unique -- two accounts can hold the same code at the same moment --
	// and because a wrong guess still has to find the record in order to
	// be counted against the attempt limit.
	EmailLoginCode EmailLoginKind = "code"
)

type EmailLoginStore added in v0.4.0

type EmailLoginStore interface {
	CreateEmailLoginToken(ctx context.Context, t *EmailLoginToken) error
	// GetEmailLoginTokenByHash resolves a magic link.
	GetEmailLoginTokenByHash(ctx context.Context, hash string) (*EmailLoginToken, error)
	// GetEmailLoginTokenByEmail resolves the outstanding token for an
	// address, which is how a code redemption finds the record it must
	// count a failed guess against.
	GetEmailLoginTokenByEmail(ctx context.Context, email string, kind EmailLoginKind) (*EmailLoginToken, error)
	// MarkEmailLoginTokenUsed marks the token used, and reports
	// ErrNotFound if it was already used or is gone.
	//
	// This is a compare-and-set, not an update, and the difference is the
	// single-use property. Reading a token, seeing UsedAt is nil, and
	// writing it back lets two concurrent redemptions of one link both
	// observe an unused token and both succeed -- one credential, two
	// sessions. In SQL:
	//
	//	UPDATE email_login_tokens SET used_at = $2
	//	 WHERE id = $1 AND used_at IS NULL
	//
	// and no rows affected means somebody else won, which is ErrNotFound.
	// Callers treat winning this as the authorisation to proceed, so an
	// implementation that always reports success has removed the control
	// rather than weakened it.
	MarkEmailLoginTokenUsed(ctx context.Context, id string, usedAt time.Time) error

	// IncrementEmailLoginTokenAttempts adds one to the token's failed-guess
	// count and returns the new value, or ErrNotFound if it is gone.
	//
	// Atomic for the same reason, and it matters more here: the returned
	// count is what decides when a six-digit code is burned, and read-then-
	// write loses increments under concurrency. An attacker guessing in
	// parallel would get many tries charged as one, which is the entire
	// budget MaxCodeAttempts exists to impose. In SQL:
	//
	//	UPDATE email_login_tokens SET attempts = attempts + 1
	//	 WHERE id = $1 RETURNING attempts
	//
	// Return the value the database computed, never one added in Go.
	IncrementEmailLoginTokenAttempts(ctx context.Context, id string) (int, error)
	// DeleteEmailLoginTokens removes every outstanding token of that kind
	// for the address.
	//
	// Requesting a new credential must invalidate the old one. If ten
	// codes were live at once, guessing would be ten times easier, and an
	// attacker can ask for as many as they like.
	DeleteEmailLoginTokens(ctx context.Context, email string, kind EmailLoginKind) error
}

EmailLoginStore persists outstanding magic links and sign-in codes.

type EmailLoginToken added in v0.4.0

type EmailLoginToken struct {
	ID    string
	Email string
	Kind  EmailLoginKind
	// TokenHash is the hash of the credential. For a link it is the hash
	// of the token alone; for a code it is the hash of the address and the
	// code together, so that a code is only ever valid for the address it
	// was sent to.
	TokenHash string
	// Attempts counts failed redemptions of a code. It is what makes a
	// six-digit credential survivable at all -- see MaxCodeAttempts.
	Attempts  int
	ExpiresAt time.Time
	UsedAt    *time.Time
	CreatedAt time.Time
}

EmailLoginToken is an outstanding magic link or sign-in code.

It is keyed by email rather than user id, because the account may not exist yet: a passwordless flow can create one, and it does so when the token is redeemed rather than when it is requested. See the emaillogin package for why that ordering matters.

type EmailVerificationStore

type EmailVerificationStore interface {
	CreateEmailVerificationToken(ctx context.Context, t *EmailVerificationToken) error
	GetEmailVerificationTokenByHash(ctx context.Context, hash string) (*EmailVerificationToken, error)
	MarkEmailVerificationTokenUsed(ctx context.Context, id string) error
	DeleteUserEmailVerificationTokens(ctx context.Context, userID string) error
}

EmailVerificationStore persists email verification tokens.

type EmailVerificationToken

type EmailVerificationToken struct {
	ID        string
	UserID    string
	TokenHash string
	ExpiresAt time.Time
	UsedAt    *time.Time
	CreatedAt time.Time
}

EmailVerificationToken is a single-use, time-limited token e-mailed to a user to prove ownership of their address. Only its hash is persisted.

type FailedLoginAttempt

type FailedLoginAttempt struct {
	ID        string
	Email     string
	IPAddress string
	CreatedAt time.Time
}

FailedLoginAttempt records one bad login attempt, keyed by email so lockout can be checked before a matching user is even confirmed to exist (this avoids leaking account existence through timing/behavior).

type Invitation

type Invitation struct {
	ID          string
	TeamID      string
	Email       string
	TokenHash   string
	Role        Role
	Status      InvitationStatus
	InvitedByID string
	ExpiresAt   time.Time
	AcceptedAt  *time.Time
	CreatedAt   time.Time
	UpdatedAt   time.Time
}

Invitation represents an offer for an email address to join a Team with a given Role. Only the token's hash is persisted; the raw token is handed back to the caller once, at creation or resend.

type InvitationStatus

type InvitationStatus string

InvitationStatus is the lifecycle state of an Invitation. There is deliberately no "expired" status: expiry is derived from ExpiresAt at read time rather than written back.

const (
	InvitationPending  InvitationStatus = "pending"
	InvitationAccepted InvitationStatus = "accepted"
	InvitationRevoked  InvitationStatus = "revoked"
)

type InvitationStore

type InvitationStore interface {
	CreateInvitation(ctx context.Context, i *Invitation) error
	GetInvitation(ctx context.Context, id string) (*Invitation, error)
	GetInvitationByTokenHash(ctx context.Context, hash string) (*Invitation, error)
	ListInvitationsByTeam(ctx context.Context, teamID string) ([]*Invitation, error)
	UpdateInvitation(ctx context.Context, i *Invitation) error
}

InvitationStore persists Invitation records.

type LockoutStore

type LockoutStore interface {
	RecordFailedLoginAttempt(ctx context.Context, a *FailedLoginAttempt) error
	// CountRecentFailedLoginAttempts is called on every login attempt, not
	// only on failures -- it is what the temporary lockout is derived from.
	// Index (email, created_at).
	CountRecentFailedLoginAttempts(ctx context.Context, email string, since time.Time) (int, error)
	ClearFailedLoginAttempts(ctx context.Context, email string) error
	LockAccount(ctx context.Context, userID string) error
	IsAccountLocked(ctx context.Context, userID string) (bool, error)
	UnlockAccount(ctx context.Context, userID string) error
}

LockoutStore tracks failed logins and account lockout state.

It needs TWO tables, which is not visible from the types above, and they back two DIFFERENT concepts -- do not conflate them:

  1. The attempts table (FailedLoginAttempt rows, keyed by email) backs the *temporary* lockout. authit does not store that lockout anywhere: it derives it by counting recent attempts, so it lifts on its own as they age out of the window. This is the automatic brute-force control, and it is the only one a failed login triggers.

  2. The locks table backs the *administrative* lock -- an operator disabling an account until an operator re-enables it. It has no canonical authit type at all: LockAccount, IsAccountLocked and UnlockAccount are insert/exists/delete over a set of user ids, not CRUD over a struct, so its shape is entirely yours. All authit requires is that its user-id column is UNIQUE, so locking an already-locked account is idempotent rather than an error.

Nothing inside authit calls LockAccount or UnlockAccount; they exist for the host to call. Earlier versions locked an account automatically after MaxFailedLoginAttempts, which -- because the lock had no expiry and nothing ever cleared it -- let anyone who knew an address disable it permanently with a handful of wrong passwords. If you are upgrading, note that rows written to your locks table by that behaviour are still honoured and must be cleared manually.

Implementing only the attempts table compiles cleanly, and every automatic control still works; IsAccountLocked is then the only method reached, on every login. See schema.sql (`account_locks`).

type Member

type Member struct {
	ID          string
	TeamID      string
	UserID      *string
	Role        Role
	DisplayName string
	Email       string
	IsActive    bool
	CreatedAt   time.Time
	UpdatedAt   time.Time
}

Member is the join between a User and a Team, carrying the role that governs authorization within that team. UserID is nullable so a team can track a member (e.g. pending an invitation, or a login-less contact) before or without a linked User.

type MemberStore

type MemberStore interface {
	CreateMember(ctx context.Context, m *Member) error
	GetMember(ctx context.Context, id string) (*Member, error)
	GetMemberByUserAndTeam(ctx context.Context, userID, teamID string) (*Member, error)
	ListMembersByTeam(ctx context.Context, teamID string) ([]*Member, error)
	ListMembershipsByUser(ctx context.Context, userID string) ([]*Member, error)
	UpdateMember(ctx context.Context, m *Member) error
	DeleteMember(ctx context.Context, id string) error
}

MemberStore persists Member records.

type PINCredential added in v0.7.0

type PINCredential struct {
	UserID    string
	PINHash   string
	CreatedAt time.Time
	UpdatedAt time.Time
}

PINCredential is a user's short numeric PIN, for the pin package's sign-in on a field device. Only its hash is stored, in the same self-describing formats as a password hash (PHC argon2id, or $2 bcrypt for a PIN imported from elsewhere).

A PIN is low-entropy by design: six digits are a million guesses, which no hash makes expensive enough to survive an offline attack on a leaked table. What protects it is the online limit on wrong guesses, kept in the LockoutStore under a key of the pin package's own, so the lock state has no column here.

type PINStore added in v0.7.0

type PINStore interface {
	// SetPINCredential creates the user's PIN, or replaces the one they
	// have. UserID must be UNIQUE: a user has one PIN.
	SetPINCredential(ctx context.Context, c *PINCredential) error
	GetPINCredential(ctx context.Context, userID string) (*PINCredential, error)
	// DeletePINCredential is idempotent: deleting a PIN that is not there
	// is not an error.
	DeletePINCredential(ctx context.Context, userID string) error
}

PINStore persists one PINCredential per user.

type PasswordResetStore

type PasswordResetStore interface {
	CreatePasswordResetToken(ctx context.Context, t *PasswordResetToken) error
	GetPasswordResetTokenByHash(ctx context.Context, hash string) (*PasswordResetToken, error)
	MarkPasswordResetTokenUsed(ctx context.Context, id string) error
	DeleteUserPasswordResetTokens(ctx context.Context, userID string) error
}

PasswordResetStore persists password reset tokens.

type PasswordResetToken

type PasswordResetToken struct {
	ID        string
	UserID    string
	TokenHash string
	ExpiresAt time.Time
	UsedAt    *time.Time
	CreatedAt time.Time
}

PasswordResetToken is a single-use, time-limited token e-mailed to a user who requested a password reset. Only its hash is persisted.

type PendingTwoFactorSession

type PendingTwoFactorSession struct {
	ID        string
	UserID    string
	TokenHash string
	ExpiresAt time.Time
	CreatedAt time.Time
}

PendingTwoFactorSession is the short-lived token issued after a correct password when the account has TOTP enabled; it must be exchanged for a real session by presenting a valid TOTP or backup code.

type PendingTwoFactorStore

type PendingTwoFactorStore interface {
	CreatePendingTwoFactorSession(ctx context.Context, s *PendingTwoFactorSession) error
	GetPendingTwoFactorSessionByHash(ctx context.Context, hash string) (*PendingTwoFactorSession, error)
	DeletePendingTwoFactorSession(ctx context.Context, id string) error
}

PendingTwoFactorStore persists pending 2FA sessions.

type PersonalAccessToken

type PersonalAccessToken struct {
	ID         string
	UserID     string
	Name       string
	TokenHash  string
	Scopes     []string
	ExpiresAt  *time.Time
	LastUsedAt *time.Time
	RevokedAt  *time.Time
	CreatedAt  time.Time
}

PersonalAccessToken is a long-lived, named, scoped bearer credential a user creates for themselves — for a CLI, a script, or any caller that isn't going through an interactive login. Unlike RefreshToken, it is not paired with a short-lived access token: the raw value itself is the bearer credential, verified on every request via its hash.

type PersonalAccessTokenStore

type PersonalAccessTokenStore interface {
	CreatePersonalAccessToken(ctx context.Context, t *PersonalAccessToken) error
	GetPersonalAccessToken(ctx context.Context, id string) (*PersonalAccessToken, error)
	GetPersonalAccessTokenByHash(ctx context.Context, hash string) (*PersonalAccessToken, error)
	ListPersonalAccessTokensByUser(ctx context.Context, userID string) ([]*PersonalAccessToken, error)
	UpdatePersonalAccessToken(ctx context.Context, t *PersonalAccessToken) error
}

PersonalAccessTokenStore persists PersonalAccessToken records.

type RefreshToken

type RefreshToken struct {
	ID        string
	UserID    string
	TokenHash string
	ExpiresAt time.Time
	RevokedAt *time.Time
	UserAgent string
	IPAddress string
	// TeamID is the team the session acts in, nil for none. It is set by
	// user.Service.SwitchTeam and carried across rotation, and it reaches
	// the validated claims as Claims.TeamID. An adapter must persist it:
	// one that drops it fails closed, as a session without a team, but
	// SwitchTeam then appears to do nothing. A schema with teams can
	// reference them from it with a cascade, so deleting a team ends the
	// sessions acting in it.
	TeamID    *string
	CreatedAt time.Time
}

RefreshToken is a server-side session record. The raw token is only ever returned to the caller once, at issuance; only TokenHash is persisted.

type RefreshTokenStore

type RefreshTokenStore interface {
	CreateRefreshToken(ctx context.Context, t *RefreshToken) error
	GetRefreshTokenByHash(ctx context.Context, hash string) (*RefreshToken, error)
	RevokeRefreshToken(ctx context.Context, id string) error
	// ConsumeRefreshToken revokes the token only if it is not revoked yet,
	// and reports whether this call was the one that revoked it. It must be
	// a single atomic step: of any number of concurrent calls on one id,
	// exactly one reports true. Refresh relies on it to make a refresh
	// token single-use; a read followed by an unconditional write lets
	// every concurrent caller win. An unknown id reports false.
	ConsumeRefreshToken(ctx context.Context, id string) (bool, error)
	// TouchRefreshToken pushes a token's expiry out to expiresAt, and
	// reports ErrNotFound if it is gone or already revoked.
	//
	// It exists for the sliding expiry of user.SessionModeOpaque, where
	// this row is the session and using it keeps it alive. Refusing a
	// revoked row matters: without that check a session revoked between a
	// request's lookup and its extension would be quietly resurrected with
	// a fresh lifetime. In SQL:
	//
	//	UPDATE refresh_tokens SET expires_at = $2
	//	 WHERE id = $1 AND revoked_at IS NULL
	//
	// Nothing calls it in the default SessionModeJWT, where a refresh
	// token is rotated rather than extended.
	TouchRefreshToken(ctx context.Context, id string, expiresAt time.Time) error
	RevokeAllUserRefreshTokens(ctx context.Context, userID string) error
	ListActiveRefreshTokens(ctx context.Context, userID string) ([]*RefreshToken, error)
}

RefreshTokenStore persists refresh tokens, which double as the list of a user's active sessions.

type Role

type Role string

Role is a member's permission level within a team. The three-tier default below is a starting point, not a closed set — host applications may store and check any string value they like; authit's team package only assigns special meaning to RoleOwner (last-owner protections).

Roles are per-team by design, and that is a real limit rather than a gap waiting to be filled. A Role only exists attached to a Member, and a Member only exists attached to a Team, so there is no way to express a principal whose identity spans teams — a platform-level auditor, a consultant, a support engineer, a coach working across many client organizations. Such an identity belongs in your own model, joined to authit by user id, not squeezed in here: the workarounds (a synthetic team everyone joins, or a membership row per team) both break as soon as the principal needs to reach a team it holds no membership in.

The split that survives: authit answers "who is this", your model answers "what may they do". See the team package's doc comment.

const (
	RoleOwner  Role = "owner"
	RoleAdmin  Role = "admin"
	RoleMember Role = "member"
)

type Superuser

type Superuser struct {
	ID           string
	Email        string
	PasswordHash string
	DisplayName  string
	IsActive     bool
	CreatedBy    *string
	LastLoginAt  *time.Time
	CreatedAt    time.Time
	UpdatedAt    time.Time
}

Superuser is an operator identity, deliberately unrelated to Team/Member roles: it has no organization and no role field. The only way to create one is through the superuser package's API (never exposed over a public registration endpoint), so a compromised user-facing flow can never mint one.

type SuperuserRefreshToken

type SuperuserRefreshToken struct {
	ID          string
	SuperuserID string
	TokenHash   string
	ExpiresAt   time.Time
	RevokedAt   *time.Time
	UserAgent   string
	IPAddress   string
	CreatedAt   time.Time
}

SuperuserRefreshToken is the admin-plane equivalent of RefreshToken, kept in its own store/table so a leaked user-session store dump can't be replayed as an admin session.

type SuperuserRefreshTokenStore

type SuperuserRefreshTokenStore interface {
	CreateSuperuserRefreshToken(ctx context.Context, t *SuperuserRefreshToken) error
	GetSuperuserRefreshTokenByHash(ctx context.Context, hash string) (*SuperuserRefreshToken, error)
	RevokeSuperuserRefreshToken(ctx context.Context, id string) error
	// ConsumeSuperuserRefreshToken is RefreshTokenStore.ConsumeRefreshToken
	// for operator tokens: revoke only if live, atomically, and report
	// whether this call did it.
	ConsumeSuperuserRefreshToken(ctx context.Context, id string) (bool, error)
	RevokeAllSuperuserRefreshTokens(ctx context.Context, superuserID string) error
	// ListActiveSuperuserRefreshTokens returns the unrevoked, unexpired
	// tokens for one operator, oldest first: their sessions.
	ListActiveSuperuserRefreshTokens(ctx context.Context, superuserID string) ([]*SuperuserRefreshToken, error)
}

SuperuserRefreshTokenStore persists admin-plane refresh tokens.

type SuperuserStore

type SuperuserStore interface {
	CreateSuperuser(ctx context.Context, s *Superuser) error
	GetSuperuserByID(ctx context.Context, id string) (*Superuser, error)
	GetSuperuserByEmail(ctx context.Context, email string) (*Superuser, error)
	ListSuperusers(ctx context.Context) ([]*Superuser, error)
	UpdateSuperuser(ctx context.Context, s *Superuser) error
	CountSuperusers(ctx context.Context) (int, error)
}

SuperuserStore persists Superuser records.

type SuperuserTwoFactorStore added in v0.4.0

type SuperuserTwoFactorStore interface {
	CreateSuperuserTOTPSettings(ctx context.Context, t *TOTPSettings) error
	GetSuperuserTOTPSettings(ctx context.Context, superuserID string) (*TOTPSettings, error)
	UpdateSuperuserTOTPSettings(ctx context.Context, t *TOTPSettings) error
	DeleteSuperuserTOTPSettings(ctx context.Context, superuserID string) error

	CreateSuperuserPendingTwoFactorSession(ctx context.Context, p *PendingTwoFactorSession) error
	GetSuperuserPendingTwoFactorSessionByHash(ctx context.Context, hash string) (*PendingTwoFactorSession, error)
	DeleteSuperuserPendingTwoFactorSession(ctx context.Context, id string) error
}

SuperuserTwoFactorStore persists an operator's TOTP enrollment and the pending sessions between a correct password and a correct code. It reuses the user plane's row types, with UserID carrying the superuser id, but its own tables, for the reason SuperuserRefreshToken has its own.

type TOTPSettings

type TOTPSettings struct {
	ID                 string
	UserID             string
	SecretEncrypted    []byte
	Enabled            bool
	VerifiedAt         *time.Time
	RecoveryCodeHashes []string
	RecoveryCodesUsed  int
	CreatedAt          time.Time
	UpdatedAt          time.Time
}

TOTPSettings holds a user's TOTP secret (encrypted at rest by the caller before it reaches the store) and backup codes (hashed).

The field names are worth reading before writing the table: the obvious guesses (`confirmed`, `backup_codes`) are not what this type has. Enabled is the on/off flag, VerifiedAt records when enrollment was confirmed, and the backup codes are RecoveryCodeHashes plus a RecoveryCodesUsed counter.

RecoveryCodeHashes is a []string with no single obvious storage: a Postgres text[], a join table and a JSON column are all defensible, and the choice is yours -- see schema.sql, which uses text[].

type TOTPStore

type TOTPStore interface {
	CreateTOTPSettings(ctx context.Context, t *TOTPSettings) error
	GetTOTPSettingsByUserID(ctx context.Context, userID string) (*TOTPSettings, error)
	UpdateTOTPSettings(ctx context.Context, t *TOTPSettings) error
	DeleteTOTPSettings(ctx context.Context, userID string) error
}

TOTPStore persists TOTP enrollment state.

type Team

type Team struct {
	ID        string
	Name      string
	Slug      string
	OwnerID   string
	CreatedAt time.Time
	UpdatedAt time.Time
}

Team is an organization/tenant that users belong to via Member records.

type TeamStore

type TeamStore interface {
	CreateTeam(ctx context.Context, t *Team) error
	GetTeam(ctx context.Context, id string) (*Team, error)
	GetTeamBySlug(ctx context.Context, slug string) (*Team, error)
	UpdateTeam(ctx context.Context, t *Team) error
	DeleteTeam(ctx context.Context, id string) error
}

TeamStore persists Team records.

type TxRunner added in v0.4.0

type TxRunner interface {
	RunInTx(ctx context.Context, fn func(ctx context.Context) error) error
}

TxRunner runs a group of store operations atomically.

It is optional everywhere it appears: leave the field nil and the flows below run exactly as they always have, as a sequence of independent writes. Supplying one closes the windows where a crash between two writes leaves inconsistent state — a rotated refresh token that was revoked but never replaced, a team with no owner, an invitation accepted by a member row that was never created.

The contract, which is the whole difficulty

authit calls store methods with the context RunInTx hands to fn. It has no other way to tell a store "this call belongs to that transaction": every port takes a context and nothing else in common, and widening them all to take a transaction handle would put a database concept into interfaces whose entire purpose is not having one.

So the contract is:

Every store method called with the context passed to fn MUST take part
in the transaction. Every store method called with any other context
MUST NOT.

That is a real obligation on the implementation, not a formality. The usual shape is for RunInTx to begin a transaction, stash the handle in the context it passes to fn, and for each store method to look for that handle and use it when present, falling back to the pool when absent. An implementation whose stores ignore the context and always use the pool will compile, pass its own tests, and silently provide no atomicity at all — every write inside fn commits independently, and returning an error from fn rolls back nothing.

If you cannot honour that, leave the field nil. Losing atomicity you never had is better than believing in atomicity you do not have.

Semantics

  • fn returning nil commits; fn returning an error rolls back, and RunInTx returns that error unchanged so callers can still match sentinels with errors.Is.
  • A panic inside fn must roll back before it propagates.
  • Nesting is not used by authit and need not be supported.
  • Retrying fn on serialization failure is allowed but not required. If you do retry, note that fn may run more than once, so authit keeps side effects that are not store writes — audit events in particular — outside the transaction.

type User

type User struct {
	ID              string
	Email           string
	PasswordHash    string
	EmailVerified   bool
	EmailVerifiedAt *time.Time
	CreatedAt       time.Time
	UpdatedAt       time.Time
}

User is a login-capable identity. Host applications are free to store additional profile fields elsewhere and join on ID; authit only needs the fields below to authenticate.

type UserStore

type UserStore interface {
	CreateUser(ctx context.Context, u *User) error
	GetUserByID(ctx context.Context, id string) (*User, error)
	GetUserByEmail(ctx context.Context, email string) (*User, error)
	UpdateUser(ctx context.Context, u *User) error
}

UserStore persists User records.

type WebAuthnChallenge added in v0.4.0

type WebAuthnChallenge struct {
	ID string
	// TokenHash is the hash of the handle held by the browser. The handle
	// itself is never stored, for the same reason a refresh token is not:
	// a leaked table should not yield live credentials.
	//
	// It must be UNIQUE. It is the only thing a ceremony is found by.
	TokenHash string
	// UserID is the account a *registration* ceremony belongs to, and nil
	// for a discoverable login, which names no user by design.
	//
	// authit never looks a challenge up by it, and never reads it back.
	// It is denormalised out of Data so a host can put a foreign key here
	// and have in-flight ceremonies cascade away with the account -- the
	// same reason WebAuthnCredential carries fields it does not strictly
	// need.
	UserID    *string
	Data      []byte
	ExpiresAt time.Time
	CreatedAt time.Time
}

WebAuthnChallenge is one in-flight WebAuthn ceremony.

A ceremony spans two requests -- the browser is handed options, and comes back with a signature over the challenge inside them -- so the challenge has to survive in between. It survives here, and not in the caller's hands, because it is what the signature is checked against: a caller able to substitute it can present an assertion captured earlier and have it verified against its own expectations.

Data is the record

Data is the ceremony state, opaque to the store: whatever the passkey package serialises, which must round-trip byte for byte. Store it as bytes, never as text -- it is not UTF-8, and a text column will mangle it. Nothing in the store may interpret it.

type WebAuthnChallengeStore added in v0.4.0

type WebAuthnChallengeStore interface {
	CreateWebAuthnChallenge(ctx context.Context, c *WebAuthnChallenge) error

	// ConsumeWebAuthnChallenge atomically deletes the challenge and returns
	// what it held, or ErrNotFound if no row matched.
	//
	// Atomic is the whole method. Two callers presenting the same handle
	// concurrently must not both receive a row: exactly one deletes it and
	// sees it, and every other gets ErrNotFound. That race is precisely
	// what lets a captured assertion be replayed, and removing it is the
	// only reason this port is not just a map.
	//
	// In SQL it is one statement:
	//
	//	DELETE FROM webauthn_challenges WHERE token_hash = $1 RETURNING ...
	//
	// What matters is that the DELETE decides, not the read. Reading the
	// row first and returning it is fine -- the read may even be stale --
	// so long as the caller returns it only when its own delete removed
	// exactly one row, and reports ErrNotFound when it removed none. A
	// read that decides, with the delete as an afterthought, is the broken
	// version: two callers both read, both return, and the assertion is
	// accepted twice.
	//
	// Expiry is not judged here. An expired row is returned like any other
	// and refused by the passkey package; consuming it is still right,
	// since a spent challenge should not linger either way.
	ConsumeWebAuthnChallenge(ctx context.Context, tokenHash string) (*WebAuthnChallenge, error)
}

WebAuthnChallengeStore persists in-flight WebAuthn ceremonies.

Two methods, and the second is the reason the port exists. There is deliberately no Get -- a caller that can read a challenge without consuming it can replay one -- no Update, because a challenge is written once, and no sweeper, because no other port here ships one (see schema.sql for the DELETE, which is yours to schedule).

type WebAuthnCredential added in v0.4.0

type WebAuthnCredential struct {
	ID     string
	UserID string
	// CredentialID is the authenticator's own identifier for this
	// credential, as raw bytes. Not a string: it is arbitrary binary.
	CredentialID []byte
	// Data is the opaque, authoritative credential record. See above.
	Data []byte

	// Name is a user-facing label ("MacBook Touch ID", "YubiKey"), so
	// somebody with four passkeys can tell which to revoke.
	Name string
	// Transports are the authenticator's reported transports ("internal",
	// "usb", "nfc", "hybrid"), useful for tailoring UI prompts.
	Transports []string
	// BackupEligible and BackupState describe whether the credential is a
	// syncing passkey and whether it is currently backed up. A credential
	// that is not backup-eligible lives on exactly one device, so losing
	// that device loses the credential -- which is worth telling a user
	// before it is their only one.
	BackupEligible bool
	BackupState    bool
	// CloneWarning records that an assertion arrived with a signature
	// counter that did not advance, which is evidence the credential's
	// private key exists in more than one place. It is a column rather
	// than only a field inside Data so that "find every compromised
	// credential" is a query.
	CloneWarning bool

	LastUsedAt *time.Time
	CreatedAt  time.Time
	UpdatedAt  time.Time
}

WebAuthnCredential is a registered authenticator — a passkey in a phone or password manager, or a hardware security key — bound to a User.

Data is the authoritative record

Everything a signature check needs lives in Data, opaque to the store and to authit's own ports: it is whatever the passkey package's WebAuthn implementation serialises, and it must round-trip byte for byte. Store it as bytes, never as text — it is not UTF-8, and a text column will mangle it.

The remaining fields are denormalised out of Data and rewritten on every update. They exist so a host can index, list and display credentials without decoding a blob, and so an operator can query for a clone warning. When they disagree with Data, Data wins.

The uniqueness that matters

CredentialID must be UNIQUE. It is what an assertion is looked up by, and therefore the only thing deciding whose credential just signed the challenge; a duplicate makes that lookup a coin flip between two accounts.

type WebAuthnCredentialStore added in v0.4.0

type WebAuthnCredentialStore interface {
	CreateWebAuthnCredential(ctx context.Context, c *WebAuthnCredential) error
	GetWebAuthnCredential(ctx context.Context, id string) (*WebAuthnCredential, error)
	// GetWebAuthnCredentialByCredentialID is the lookup every assertion
	// makes. Its index must be UNIQUE; see the type's documentation.
	GetWebAuthnCredentialByCredentialID(ctx context.Context, credentialID []byte) (*WebAuthnCredential, error)
	ListWebAuthnCredentialsByUser(ctx context.Context, userID string) ([]*WebAuthnCredential, error)
	UpdateWebAuthnCredential(ctx context.Context, c *WebAuthnCredential) error
	DeleteWebAuthnCredential(ctx context.Context, id string) error
}

WebAuthnCredentialStore persists registered authenticators.

Jump to

Keyboard shortcuts

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