package pgstore import ( "context" "crypto/rand" "encoding/base32" "errors" "fmt" "time" "github.com/jackc/pgx/v5" "textmachine/platform/internal/login" "textmachine/platform/internal/money" ) // newID mints an opaque identifier. Opaque on purpose: an id that encodes a row number tells a // caller how many accounts exist and lets them guess a neighbour's. func newID(prefix string) string { var b [10]byte rand.Read(b[:]) return prefix + "_" + base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b[:]) } // PutLoginState stores one in-flight authorization request. func (s *Store) PutLoginState(ctx context.Context, st login.State) error { err := s.q.PutLoginState(ctx, PutLoginStateParams{ StateSha256: st.Hash, Provider: st.Provider, Issuer: st.Issuer, Nonce: st.Nonce, CodeVerifier: st.Verifier, ReturnTo: st.ReturnTo, StartID: st.StartID, CreatedAt: st.CreatedAt, ExpiresAt: st.ExpiresAt, }) if err != nil { return fmt.Errorf("pgstore: put login state: %w", err) } return nil } // TakeLoginState consumes the state. Deleting and returning in ONE statement is what makes it // single-use under concurrency: a second callback with the same state deletes nothing and gets // nothing, with no window between the check and the removal. func (s *Store) TakeLoginState(ctx context.Context, hash []byte, now time.Time) (login.State, error) { row, err := s.q.TakeLoginState(ctx, TakeLoginStateParams{StateSha256: hash, Now: now}) if errors.Is(err, pgx.ErrNoRows) { return login.State{}, login.ErrNoState } if err != nil { return login.State{}, fmt.Errorf("pgstore: take login state: %w", err) } // Six of these eight are strings, and they used to be filled by position. PD-62 is what happens // when that mapping slips. return login.State{ Hash: hash, Provider: row.Provider, Issuer: row.Issuer, Nonce: row.Nonce, Verifier: row.CodeVerifier, ReturnTo: row.ReturnTo, StartID: row.StartID, CreatedAt: row.CreatedAt, ExpiresAt: row.ExpiresAt, }, nil } // DeleteExpiredLoginStates is the sweep for abandoned logins. func (s *Store) DeleteExpiredLoginStates(ctx context.Context, now time.Time) (int64, error) { n, err := s.q.DeleteExpiredLoginStates(ctx, now) if err != nil { return 0, fmt.Errorf("pgstore: sweep login states: %w", err) } return n, nil } // DeleteOldLoginEvents applies the journal's retention. /auth/callback writes a row on every // refusal and needs no credential to do it, so a journal that only grows is a liability rather // than an audit. func (s *Store) DeleteOldLoginEvents(ctx context.Context, before time.Time) (int64, error) { n, err := s.q.DeleteOldLoginEvents(ctx, before) if err != nil { return 0, fmt.Errorf("pgstore: sweep login journal: %w", err) } return n, nil } // UpsertIdentity resolves (provider, subject) to an account. // // The pair is the ONLY key: an unknown pair always creates a new user, whatever address it arrives // with. Attaching a second provider to an existing account is an authenticated action elsewhere, // never a side effect of signing in. Why, in full: 00005_identity_oauth.sql. // // The signup grant is written in the SAME transaction as the account. A user that exists without // their free tier — or a grant against a user that failed to commit — is not a state worth having. func (s *Store) UpsertIdentity(ctx context.Context, in login.Identity, now time.Time, signupGrant int64) (string, error) { // One retry: two first logins of the same brand-new identity can race, and the loser sees the // row the winner inserted. for attempt := range 2 { userID, err := s.upsertIdentityOnce(ctx, in, now, signupGrant) if err == nil { return userID, nil } if !errors.Is(err, errIdentityRace) || attempt == 1 { return "", err } } return "", errIdentityRace } var errIdentityRace = errors.New("pgstore: identity created concurrently") func (s *Store) upsertIdentityOnce(ctx context.Context, in login.Identity, now time.Time, signupGrant int64) (string, error) { var userID string err := s.inTx(ctx, func(tx pgx.Tx) error { return upsertIdentityTx(ctx, tx, in, now, signupGrant, &userID) }) return userID, err } // UserByIdentity resolves a provider's subject to the account it belongs to, without creating one. // // It exists for the seeding command, which signs in over HTTP — through the same door a user walks — // and then needs the account id to credit it from the admin side. Answering ErrNoAccount rather than // minting anything keeps the CREATION of an account on the one path that also writes its signup // grant. func (s *Store) UserByIdentity(ctx context.Context, provider, subject string) (string, error) { userID, err := s.q.UserByIdentity(ctx, UserByIdentityParams{Provider: provider, Subject: subject}) if errors.Is(err, pgx.ErrNoRows) { return "", ErrNoAccount } if err != nil { return "", fmt.Errorf("pgstore: read identity: %w", err) } return userID, nil } func upsertIdentityTx(ctx context.Context, tx pgx.Tx, in login.Identity, now time.Time, signupGrant int64, out *string) error { // Bound to the CALLER'S transaction: the lock taken below and every write that follows it belong // to one transaction or the identity can be created twice. q := New(tx) userID, err := q.LockIdentity(ctx, LockIdentityParams{Provider: in.Provider, Subject: in.Subject}) switch { case err == nil: // Known identity. The address is refreshed only when the provider says it is verified — // an unverified one is kept on the identity and never promoted to the account. if err := q.RefreshIdentity(ctx, RefreshIdentityParams{ Provider: in.Provider, Subject: in.Subject, Email: nullableText(in.Email), EmailVerified: in.EmailVerified, Now: now, }); err != nil { return fmt.Errorf("pgstore: refresh identity: %w", err) } if in.EmailVerified && in.Email != "" { if err := q.RefreshAccountEmail(ctx, RefreshAccountEmailParams{ID: userID, Email: in.Email}); err != nil { return fmt.Errorf("pgstore: refresh account email: %w", err) } } case errors.Is(err, pgx.ErrNoRows): userID = newID("u") // The account's address is set only from a VERIFIED one; an unverified address stays on the // identity and is never promoted here. var email *string if in.EmailVerified { email = nullableText(in.Email) } if err := q.CreateUser(ctx, CreateUserParams{ID: userID, Email: email, CreatedAt: now}); err != nil { return fmt.Errorf("pgstore: create user: %w", err) } created, err := q.CreateIdentity(ctx, CreateIdentityParams{ Provider: in.Provider, Subject: in.Subject, UserID: userID, Email: nullableText(in.Email), EmailVerified: in.EmailVerified, Now: now, }) if err != nil { return fmt.Errorf("pgstore: create identity: %w", err) } if created == 0 { return errIdentityRace } if signupGrant > 0 { // A brand-new account cannot have spent this key, so "already applied" is not a case. if _, err := appendLedger(ctx, tx, userID, "grant", money.MicroUSD(signupGrant), "signup", userID, "free tier", now); err != nil { return err } } default: return fmt.Errorf("pgstore: find identity: %w", err) } *out = userID return nil } // RevokeUserSessions ends every session of a user at once. func (s *Store) RevokeUserSessions(ctx context.Context, userID string, now time.Time) (int64, error) { n, err := s.q.RevokeUserSessions(ctx, RevokeUserSessionsParams{UserID: userID, Now: now}) if err != nil { return 0, fmt.Errorf("pgstore: revoke user sessions: %w", err) } return n, nil } // RecordLogin appends to the login journal. func (s *Store) RecordLogin(ctx context.Context, ev login.LoginEvent) error { err := s.q.RecordLogin(ctx, RecordLoginParams{ UserID: nullableText(ev.UserID), Provider: ev.Provider, Outcome: ev.Outcome, Reason: ev.Reason, IpPrefix: ev.IPPrefix, Client: ev.Client, At: ev.At, }) if err != nil { return fmt.Errorf("pgstore: record login: %w", err) } return nil } // LoginEntry is one journal line as an operator reads it. type LoginEntry struct { Provider string Outcome string Reason string IPPrefix string Client string At time.Time } // RecentLogins backs "where have I signed in from" and the admin CLI. func (s *Store) RecentLogins(ctx context.Context, userID string, limit int) ([]LoginEntry, error) { rows, err := s.q.RecentLogins(ctx, RecentLoginsParams{UserID: userID, Lim: int64(limit)}) if err != nil { return nil, fmt.Errorf("pgstore: recent logins: %w", err) } out := make([]LoginEntry, 0, len(rows)) for _, r := range rows { // Five of these six are strings. By name, so that a transposition is a compile error rather // than an operator reading an IP prefix under PROVIDER. out = append(out, LoginEntry{ Provider: r.Provider, Outcome: r.Outcome, Reason: r.Reason, IPPrefix: r.IpPrefix, Client: r.Client, At: r.At, }) } return out, nil } // nullable turns "" into SQL NULL: an empty string and "no address" are different facts, and a // unique index would treat them differently too. func nullable(s string) any { if s == "" { return nil } return s } // nullableText is `nullable` for the generated layer, which is typed: same rule, a *string instead // of an `any`. Kept separate rather than changing `nullable`, whose other callers are in the // hand-written read model. func nullableText(s string) *string { if s == "" { return nil } return &s }