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

authpg

package
v0.6.7 Latest Latest
Warning

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

Go to latest
Published: Oct 3, 2026 License: MIT Imports: 16 Imported by: 0

Documentation

Overview

Package authpg implements the auth stores over the tables `rig setup-project` creates.

The auth packages take interfaces because an application might keep its accounts somewhere unexpected. Almost none do: they run the migrations rig wrote, against the schema rig designed, and then have to write four hundred lines of SQL to connect the two. This is that SQL, written once.

If you changed the foundation schema, implement the interfaces yourself. If you did not, wiring authentication is New and nothing else.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type APIKeyStore

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

APIKeyStore keeps machine credentials in rig_api_key.

func (*APIKeyStore) ByKeyID

func (s *APIKeyStore) ByKeyID(ctx context.Context, keyID string) (*apikey.Key, error)

ByKeyID implements apikey.Store.

func (*APIKeyStore) Find

func (s *APIKeyStore) Find(ctx context.Context, tenantID, id uuid.UUID) (*apikey.Key, error)

Find implements apikey.Store.

func (*APIKeyStore) InTx

func (s *APIKeyStore) InTx(ctx context.Context, fn func(ctx context.Context) error) error

InTx implements apikey.Store.

func (*APIKeyStore) Insert

func (s *APIKeyStore) Insert(ctx context.Context, k *apikey.Key) error

Insert implements apikey.Store.

func (*APIKeyStore) List

func (s *APIKeyStore) List(ctx context.Context, tenantID uuid.UUID) ([]*apikey.Key, error)

List implements apikey.Store.

func (*APIKeyStore) Revoke

func (s *APIKeyStore) Revoke(ctx context.Context, tenantID, id uuid.UUID, at time.Time) error

Revoke implements apikey.Store.

func (*APIKeyStore) SetExpiry

func (s *APIKeyStore) SetExpiry(ctx context.Context, tenantID, id uuid.UUID, at time.Time) error

SetExpiry implements apikey.Store.

func (*APIKeyStore) TouchLastUsed

func (s *APIKeyStore) TouchLastUsed(ctx context.Context, id uuid.UUID, at time.Time) error

TouchLastUsed implements apikey.Store.

type AccountStore

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

AccountStore keeps identities, accounts, credentials, and single-use links.

Two tables where there used to be one: identity is the person, global and unique on their address, and account is that person inside one tenant. Every query below belongs to one side or the other, and the ones that cross say so.

func (*AccountStore) AccountForIdentity

func (s *AccountStore) AccountForIdentity(ctx context.Context, tenantID, identityID uuid.UUID) (*account.Account, error)

AccountForIdentity implements account.Store.

func (*AccountStore) AccountsForIdentity

func (s *AccountStore) AccountsForIdentity(ctx context.Context, identityID uuid.UUID) ([]*account.Account, error)

AccountsForIdentity implements account.Store.

The one query in this package with no tenant predicate, deliberately: its whole purpose is to reach the tenants the caller is not in. It is unexported business — nothing here is returned over HTTP — and what it feeds is revoking every session a person has.

func (*AccountStore) ChargeVerificationAttempt added in v0.6.0

func (s *AccountStore) ChargeVerificationAttempt(
	ctx context.Context, id uuid.UUID, max int, at time.Time,
) (int, bool, error)

ChargeVerificationAttempt implements account.Store.

One statement, which is the whole requirement. A read followed by a write is a window two concurrent guesses both fit through, and against a six-digit secret that window is free attempts.

The revocation is in the same UPDATE rather than a second one, so a code that has just run out of attempts is dead by the time the row is unlocked. Revoked rather than a state of its own: account.Verification.Usable already refuses a revoked row and so does the mail queue's rotation, so nothing else has to learn that a ceiling exists.

func (*AccountStore) ConsumeVerification

func (s *AccountStore) ConsumeVerification(ctx context.Context, id uuid.UUID, at time.Time) (bool, error)

ConsumeVerification implements account.Store.

The `consumed_at IS NULL` clause is what makes it single-use under concurrency: two requests racing to redeem one link both run the UPDATE, and exactly one of them affects a row.

func (*AccountStore) CreateVerification

func (s *AccountStore) CreateVerification(ctx context.Context, v *account.Verification) error

CreateVerification implements account.Store.

func (*AccountStore) FindByID

func (s *AccountStore) FindByID(ctx context.Context, tenantID, id uuid.UUID) (*account.Account, error)

FindByID implements account.Store.

func (*AccountStore) FindIdentityByEmail

func (s *AccountStore) FindIdentityByEmail(ctx context.Context, lowercased string) (*account.Identity, error)

FindIdentityByEmail implements account.Store.

The comparison is lower(email_address) with no tenant in sight, which matches the unique index the foundation creates — so this is an index lookup rather than a scan, and it agrees with what the database will enforce on insert.

func (*AccountStore) FindIdentityByID

func (s *AccountStore) FindIdentityByID(ctx context.Context, id uuid.UUID) (*account.Identity, error)

FindIdentityByID implements account.Store.

func (*AccountStore) InTx

func (s *AccountStore) InTx(ctx context.Context, fn func(ctx context.Context) error) error

InTx implements account.Store.

func (*AccountStore) Insert

func (s *AccountStore) Insert(ctx context.Context, a *account.Account) error

Insert implements account.Store.

The audit columns are written from the input rather than defaulted, because the caller knows who asked and this does not: a provisioning request through an API key has both an account and a key to record, and a seed has neither.

func (*AccountStore) InsertIdentity

func (s *AccountStore) InsertIdentity(ctx context.Context, i *account.Identity) error

InsertIdentity implements account.Store.

func (*AccountStore) InsertTenant

func (s *AccountStore) InsertTenant(ctx context.Context, t *account.Tenant) error

InsertTenant implements account.Store.

func (*AccountStore) InvitationByID added in v0.6.0

func (s *AccountStore) InvitationByID(ctx context.Context, id uuid.UUID) (*account.Invitation, error)

InvitationByID implements account.Store.

func (*AccountStore) InvitationByToken added in v0.6.0

func (s *AccountStore) InvitationByToken(ctx context.Context, hash []byte) (*account.Invitation, error)

InvitationByToken implements account.Store.

func (*AccountStore) InvitationSlot added in v0.6.0

func (s *AccountStore) InvitationSlot(
	ctx context.Context, tenantID, identityID uuid.UUID,
) (*account.Invitation, error)

InvitationSlot implements account.Store.

The predicate is the unique index's, minus the two conditions invitations already applies: rig_identity_verification_live_invitation_key is (invited_to_tenant_id, identity_id) where the row is neither consumed nor revoked. Expiry is not in the index and must not be here — an expired invitation still occupies the slot, and this is what finds it so that inviting again can clear it.

func (*AccountStore) InvitationsForIdentity

func (s *AccountStore) InvitationsForIdentity(ctx context.Context, identityID uuid.UUID) ([]account.Invitation, error)

InvitationsForIdentity implements account.Store.

No tenant predicate, deliberately, and it is the one query in this package where that is the whole point: somebody who belongs to no tenant is asking which ones have asked for them. Scoping it by tenant would be scoping it to a tenant they are not in yet.

func (*AccountStore) LastAccountForIdentity added in v0.5.0

func (s *AccountStore) LastAccountForIdentity(
	ctx context.Context, identityID uuid.UUID,
) (*account.Account, error)

LastAccountForIdentity implements account.Store.

Session roots only — `root_token_id = id` — because a root is one per sign-in where the rest of the family is one per rotation, and "where was I last" is a question about sign-ins. That predicate is also what the partial index in the last_tenant migration is on, so this is an index probe per account rather than a sort over every token an account has ever held. Consumed tokens are kept rather than deleted, so that difference only grows.

The two joins filter what the caller would have to filter anyway: an account or a tenant that has gone away is not somewhere to land, and answering with one would land somebody where AccountStore.TenantsForIdentity does not list.

func (*AccountStore) LiveVerification added in v0.6.0

func (s *AccountStore) LiveVerification(
	ctx context.Context, identityID uuid.UUID, kind account.VerificationKind,
) (*account.Verification, error)

LiveVerification implements account.Store.

func (*AccountStore) MarkIdentityVerified

func (s *AccountStore) MarkIdentityVerified(ctx context.Context, identityID uuid.UUID, at time.Time) error

MarkIdentityVerified implements account.Store.

func (*AccountStore) PendingInvitations

func (s *AccountStore) PendingInvitations(ctx context.Context, tenantID uuid.UUID) ([]account.Invitation, error)

PendingInvitations implements account.Store.

Live means not consumed, not revoked and not expired. It used to also mean "and for an account that is still there", because an account existed from the moment somebody was invited; there is none now until they accept, which is what makes this a list of people who are *not* in the tenant.

func (*AccountStore) RevokeVerification

func (s *AccountStore) RevokeVerification(ctx context.Context, id uuid.UUID, at time.Time) (bool, error)

RevokeVerification implements account.Store.

The two IS NULL clauses are what make it safe under concurrency: somebody accepting an invitation at the same moment sets consumed_at, and exactly one of the two statements affects a row.

func (*AccountStore) TenantDomains

func (s *AccountStore) TenantDomains(ctx context.Context, tenantID uuid.UUID) ([]string, error)

TenantDomains implements account.Store.

func (*AccountStore) TenantsForIdentity

func (s *AccountStore) TenantsForIdentity(ctx context.Context, identityID uuid.UUID) ([]account.Membership, error)

TenantsForIdentity implements account.Store.

The one join in this package. It has no tenant predicate for the same reason AccountStore.AccountsForIdentity has none — the question is which tenants a person belongs to — and a deleted tenant is left out, because being a member of something that no longer exists is not somewhere anybody can go.

func (*AccountStore) VerificationByHash

func (s *AccountStore) VerificationByHash(ctx context.Context, hash []byte) (*account.Verification, error)

VerificationByHash implements account.Store.

func (*AccountStore) VerificationByID

func (s *AccountStore) VerificationByID(ctx context.Context, id uuid.UUID) (*account.Verification, error)

VerificationByID implements account.Store.

type Log

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

Log is rig_auth_log: the writer, the reader, and the prune.

One type for three contracts that a caller sees separately, because they are one table and splitting them here would mean three structs holding the same connection. Which of the three a caller is given is the interface it takes: authlog.Log for the twenty call sites that record, authlog.Reader for the endpoint that shows the trail, authlog.Pruner for the retention task.

func (*Log) Prune

func (l *Log) Prune(ctx context.Context, olderThan time.Time) (int, error)

Prune implements authlog.Pruner.

In batches, and it keeps going until a batch comes back short. The subselect is what makes each statement bounded; ordering it lets the index do the work rather than the planner choosing whichever rows it happens to reach first.

func (*Log) Read

func (l *Log) Read(ctx context.Context, q authlog.Query) ([]authlog.Record, int64, error)

Read implements authlog.Reader.

Two statements, the page and the count, and the count is the expensive half: it is `count(*)` over the tenant's slice, which is what a client asking for the total is asking for. The generated list endpoints pay the same price for the same reason, so this is not a new cost, only one worth knowing about on a table that grows with every login.

The default page rides `rig_auth_log_tenant_created_idx`. Narrowing by event scans the tenant's slice instead — the index that would fix it, `(tenant_id, event, created_at DESC)`, is a write cost on the hottest write path in the system for the benefit of a screen somebody opens twice a month, so it is not there. Add it when a real log argues for it.

func (*Log) Write

func (l *Log) Write(ctx context.Context, e authlog.Entry)

Write implements authlog.Log.

A failure here is swallowed, deliberately. A login that succeeded and then returned 500 because the log was unreachable has turned an observability problem into an outage — and the rate limiter reading a slightly incomplete log is a smaller harm than the alternative.

type OAuthStore

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

OAuthStore keeps provider links in rig_identity_oauth.

func (*OAuthStore) FindAccount

func (s *OAuthStore) FindAccount(ctx context.Context, tenantID, identityID uuid.UUID) (uuid.UUID, error)

FindAccount implements oauth.Store.

func (*OAuthStore) FindIdentityByEmail

func (s *OAuthStore) FindIdentityByEmail(ctx context.Context, lowercased string) (uuid.UUID, error)

FindIdentityByEmail implements oauth.Store.

func (s *OAuthStore) FindLink(ctx context.Context, provider, subject string) (*oauth.Link, error)

FindLink implements oauth.Store.

func (*OAuthStore) JoinTenant

func (s *OAuthStore) JoinTenant(ctx context.Context, in oauth.JoinInput) (uuid.UUID, error)

JoinTenant implements oauth.Store.

This is the door, so the tenant's domain list is checked here. A provider will happily authenticate anybody with a Google account, so "sign in with Google" plus provisioning is an open door into a customer's tenant unless something says which addresses belong there.

func (*OAuthStore) LinkIdentity

func (s *OAuthStore) LinkIdentity(ctx context.Context, in oauth.LinkInput) (*oauth.Link, error)

LinkIdentity implements oauth.Store.

The conflict target is (provider, subject), which the foundation makes unique. Two sign-ins racing on a first link both insert; the second updates the address instead of failing, and both end up with the same row.

It also stamps email_verified_at, because the link is only ever offered on an address the provider has verified — [oauth.Handler.identity] refuses otherwise — and that is the same evidence OAuthStore.ProvisionIdentity acts on. Recording the link and not the evidence is what left somebody who signed up with a password and later signed in with Google unverified, on a column rather than on anything they did.

It runs on every provider sign-in that carries a verified address rather than only the first, so the upsert is doing real work here: it lands on the row already present, moves updated_at, and refreshes the address the provider reports. The stamp is the part that has to be conditional, and the CTE below is where that condition lives.

One statement rather than a transaction of its own, because OAuthStore.ProvisionIdentity calls this from inside one. Postgres runs a data-modifying CTE exactly once whether or not anything selects from it, so the update happens even though only the insert is read back. Three details the shape depends on: the update reads identity_id back out of the insert rather than trusting the input, so a conflicting upsert stamps whoever actually owns the link; `email_verified_at IS NULL` means an address verified long ago keeps its original timestamp instead of having it moved by every sign-in; and the flag is a parameter rather than a branch in Go so that the unverified case is still one round trip.

`deleted_at IS NULL` because the input carries an identifier a caller chose. Every other read here filters it, and a soft-deleted person is not somebody to record anything new about.

func (*OAuthStore) ProvisionIdentity

func (s *OAuthStore) ProvisionIdentity(ctx context.Context, in oauth.ProvisionInput) (*oauth.Link, error)

ProvisionIdentity implements oauth.Store.

The person and the link go in together. Half of this — an identity with no link — is somebody who can never sign in and never shows up in a support query, which is the worst kind of leftover.

No tenant is involved and no domain is checked here, because nothing has been granted yet: an identity on its own reaches nothing. OAuthStore.JoinTenant is where the door is.

type OutboxStore

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

OutboxStore is an account.Outbox over the foundation's tables.

func (*OutboxStore) Abandon

func (s *OutboxStore) Abandon(ctx context.Context, ids []uuid.UUID, by uuid.UUID, _ time.Time) error

Abandon implements account.Outbox.

The attempt is given back with the claim, and that is the part worth spelling out. Claim charges every row in the batch an attempt before anything is sent, so a row released without a send would have paid for one it never got — and MaxAttempts of those, on a provider slow enough to abandon the tail of every batch, would fail a delivery no notifier had ever been asked about.

claimed_by is in the WHERE clause for the same reason: past a lease that expired anyway, the row may be somebody else's now, and the attempt to give back would be theirs.

func (*OutboxStore) Claim

func (s *OutboxStore) Claim(ctx context.Context, by uuid.UUID, now time.Time, ttl time.Duration, limit int) ([]account.Delivery, error)

Claim implements account.Outbox, in the one statement that makes this contention-free.

The inner SELECT does the work: SKIP LOCKED walks past whatever another claimant is taking rather than queueing behind it, and the outer UPDATE stamps the lease. `claimed_at IS NULL OR claimed_at < now() - ttl` is what makes a crashed process recoverable at all — the row is still Pending with a stale claim, and the next dispatcher past it takes it.

func (*OutboxStore) Enqueue

func (s *OutboxStore) Enqueue(ctx context.Context, d *account.Delivery) error

Enqueue implements account.Outbox.

Through conn, so it joins the transaction that wrote the verification row. The two belong together: a link with no delivery is one nobody will ever mail, and it is invisible except as an invitation in a listing that was never sent.

func (*OutboxStore) MarkFailed

func (s *OutboxStore) MarkFailed(ctx context.Context, id uuid.UUID, reason string, _ time.Time) error

MarkFailed implements account.Outbox.

func (*OutboxStore) MarkSent

func (s *OutboxStore) MarkSent(ctx context.Context, id uuid.UUID, at time.Time) error

MarkSent implements account.Outbox.

func (*OutboxStore) MarkSkipped

func (s *OutboxStore) MarkSkipped(ctx context.Context, id uuid.UUID, reason string, _ time.Time) error

MarkSkipped implements account.Outbox.

func (*OutboxStore) Prune

func (s *OutboxStore) Prune(ctx context.Context, olderThan time.Duration, now time.Time) (int, error)

Prune deletes deliveries that are done and older than olderThan, in the same task that dispatches them.

Only the terminal states, and never a Pending row: a schema that grows forever is the state most of rig's tables are still in, and this one is written at the rate people forget their passwords. The verification rows are left alone — they are the audit trail of who was invited and when, and pruning those is a decision this queue does not get to make.

func (*OutboxStore) ReleaseClaims

func (s *OutboxStore) ReleaseClaims(ctx context.Context, by uuid.UUID, _ time.Time) (int, error)

ReleaseClaims implements account.Outbox.

The attempts stay where they are, which is the one thing Abandon does that this deliberately does not: the send it is giving up on was made.

func (*OutboxStore) Retry

func (s *OutboxStore) Retry(ctx context.Context, id uuid.UUID, at time.Time, reason string, _ time.Time) error

Retry implements account.Outbox.

func (*OutboxStore) RotateToken

func (s *OutboxStore) RotateToken(ctx context.Context, verificationID uuid.UUID, hash []byte, expiresAt time.Time) (bool, error)

RotateToken implements account.Outbox.

One statement, because the check and the write cannot be two: between reading "not consumed" and writing a hash, another pass could redeem the link, and both would then believe they held the live token. The WHERE clause is the check.

type SessionStore

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

SessionStore keeps tokens in rig_account_token.

func (*SessionStore) Families

func (s *SessionStore) Families(ctx context.Context, tenantID, accountID uuid.UUID) ([]session.Family, error)

Families implements session.Store.

The last-used time is derived from the newest token in each family rather than kept in a column, so nothing has to be written on the hot path to keep a "signed in devices" screen honest.

func (*SessionStore) Find

func (s *SessionStore) Find(ctx context.Context, id uuid.UUID) (*session.Token, error)

Find implements session.Store.

func (*SessionStore) FindIdentitySession

func (s *SessionStore) FindIdentitySession(ctx context.Context, id uuid.UUID) (*session.Identity, error)

FindIdentitySession implements session.IdentityStore.

func (*SessionStore) InTx

func (s *SessionStore) InTx(ctx context.Context, fn func(ctx context.Context) error) error

InTx implements session.Store.

func (*SessionStore) Insert

func (s *SessionStore) Insert(ctx context.Context, t *session.Token) error

Insert implements session.Store.

func (*SessionStore) InsertIdentitySession

func (s *SessionStore) InsertIdentitySession(ctx context.Context, in *session.Identity) error

InsertIdentitySession implements session.IdentityStore.

func (*SessionStore) Lock

func (s *SessionStore) Lock(ctx context.Context, id uuid.UUID) (*session.Token, error)

Lock implements session.Store.

FOR UPDATE is the whole point. Rotation reads a token, decides whether this is a first use, a retry, or a replay, and writes — and two requests doing that at once would both see an unconsumed token and both mint a child.

func (*SessionStore) MarkRotated

func (s *SessionStore) MarkRotated(ctx context.Context, id uuid.UUID, at time.Time) error

MarkRotated implements session.Store.

The WHERE clause is what makes the leeway safe. Without `rotated_at IS NULL` a replay would push the timestamp forward, and somebody replaying every twenty seconds would hold a thirty-second window open indefinitely.

func (*SessionStore) RevokeFamily

func (s *SessionStore) RevokeFamily(ctx context.Context, rootID uuid.UUID, at time.Time) ([]uuid.UUID, error)

RevokeFamily implements session.Store.

RETURNING rather than a row count, because what the caller does with it is tell every replica to forget those tokens — and one statement that both kills them and says which is one statement fewer than a count plus a second read, with no window in between for a token to be missed.

func (*SessionStore) RevokeIdentitySession

func (s *SessionStore) RevokeIdentitySession(ctx context.Context, id uuid.UUID, at time.Time) error

RevokeIdentitySession implements session.IdentityStore.

`revoked_at IS NULL` keeps the first revocation's timestamp, which is the one that says when the session actually ended.

func (*SessionStore) RevokeIdentitySessionsFor

func (s *SessionStore) RevokeIdentitySessionsFor(ctx context.Context, identityID uuid.UUID, at time.Time) (int, error)

RevokeIdentitySessionsFor implements session.IdentityStore.

func (*SessionStore) TenantFamilies

func (s *SessionStore) TenantFamilies(ctx context.Context, tenantID uuid.UUID) ([]session.Family, error)

TenantFamilies implements session.Store.

type Stores

type Stores struct {
	Accounts *AccountStore
	Sessions *SessionStore
	APIKeys  *APIKeyStore
	Log      *Log
	// Outbox is the mail queue. Handing it to account.Config is what turns
	// queueing on; leaving it out keeps the inline path.
	Outbox *OutboxStore
}

Stores are the implementations, ready to hand to the auth packages.

func New

func New(db dbx.Beginner) *Stores

New builds every store over one connection.

The connection is usually a pool. Where a transaction is needed the stores open one themselves, and a repository call made inside somebody else's transaction joins it — that is what dbx.Conn is for.

func (*Stores) OAuth

func (s *Stores) OAuth() *OAuthStore

OAuth builds the store a sign-in needs.

It is separate from New because a project can skip the OAuth part of the foundation, and a store over a table that does not exist is a runtime error waiting for its first sign-in.

Jump to

Keyboard shortcuts

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