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 ¶
- Variables
- func NormalizeEmail(email string) string
- func RunInTx(ctx context.Context, tx TxRunner, fn func(context.Context) error) error
- type Account
- type AccountStore
- type BoundDevice
- type BoundDeviceStore
- type DeviceAuthorization
- type DeviceAuthorizationStatus
- type DeviceAuthorizationStore
- type EmailLoginKind
- type EmailLoginStore
- type EmailLoginToken
- type EmailVerificationStore
- type EmailVerificationToken
- type FailedLoginAttempt
- type Invitation
- type InvitationStatus
- type InvitationStore
- type LockoutStore
- type Member
- type MemberStore
- type PINCredential
- type PINStore
- type PasswordResetStore
- type PasswordResetToken
- type PendingTwoFactorSession
- type PendingTwoFactorStore
- type PersonalAccessToken
- type PersonalAccessTokenStore
- type RefreshToken
- type RefreshTokenStore
- type Role
- type Superuser
- type SuperuserRefreshToken
- type SuperuserRefreshTokenStore
- type SuperuserStore
- type SuperuserTwoFactorStore
- type TOTPSettings
- type TOTPStore
- type Team
- type TeamStore
- type TxRunner
- type User
- type UserStore
- type WebAuthnChallenge
- type WebAuthnChallengeStore
- type WebAuthnCredential
- type WebAuthnCredentialStore
Constants ¶
This section is empty.
Variables ¶
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).
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
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));
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 ¶
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:
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.
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
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.
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
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.