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 { const q = ` insert into auth_states (state_sha256, provider, issuer, nonce, code_verifier, return_to, start_id, created_at, expires_at) values ($1, $2, $3, $4, $5, $6, $7, $8, $9)` _, err := s.pool.Exec(ctx, q, st.Hash, st.Provider, st.Issuer, st.Nonce, st.Verifier, st.ReturnTo, st.StartID, st.CreatedAt, st.ExpiresAt) if err != nil { return fmt.Errorf("pgstore: put login state: %w", err) } return nil } // ErrNoLoginState is "expired", "already used" and "never issued" at once: telling them apart on // the wire would be an oracle. var ErrNoLoginState = errors.New("pgstore: no live login state") // 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) { const q = ` delete from auth_states where state_sha256 = $1 and expires_at > $2 returning provider, issuer, nonce, code_verifier, return_to, start_id, created_at, expires_at` var st login.State st.Hash = hash err := s.pool.QueryRow(ctx, q, hash, now). Scan(&st.Provider, &st.Issuer, &st.Nonce, &st.Verifier, &st.ReturnTo, &st.StartID, &st.CreatedAt, &st.ExpiresAt) if errors.Is(err, pgx.ErrNoRows) { return login.State{}, ErrNoLoginState } if err != nil { return login.State{}, fmt.Errorf("pgstore: take login state: %w", err) } return st, nil } // DeleteExpiredLoginStates is the sweep for abandoned logins. func (s *Store) DeleteExpiredLoginStates(ctx context.Context, now time.Time) (int64, error) { tag, err := s.pool.Exec(ctx, `delete from auth_states where expires_at <= $1`, now) if err != nil { return 0, fmt.Errorf("pgstore: sweep login states: %w", err) } return tag.RowsAffected(), 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) { tag, err := s.pool.Exec(ctx, `delete from login_events where at < $1`, before) if err != nil { return 0, fmt.Errorf("pgstore: sweep login journal: %w", err) } return tag.RowsAffected(), 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 } func upsertIdentityTx(ctx context.Context, tx pgx.Tx, in login.Identity, now time.Time, signupGrant int64, out *string) error { var userID string err := tx.QueryRow(ctx, `select user_id from identities where provider = $1 and subject = $2 for update`, in.Provider, in.Subject).Scan(&userID) 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 := tx.Exec(ctx, ` update identities set email = $3, email_verified = $4, last_login_at = $5 where provider = $1 and subject = $2`, in.Provider, in.Subject, nullable(in.Email), in.EmailVerified, now); err != nil { return fmt.Errorf("pgstore: refresh identity: %w", err) } if in.EmailVerified && in.Email != "" { if _, err := tx.Exec(ctx, `update users set email = $2 where id = $1`, userID, in.Email); err != nil { return fmt.Errorf("pgstore: refresh account email: %w", err) } } case errors.Is(err, pgx.ErrNoRows): userID = newID("u") var email any if in.EmailVerified { email = nullable(in.Email) } if _, err := tx.Exec(ctx, `insert into users (id, email, created_at) values ($1, $2, $3)`, userID, email, now); err != nil { return fmt.Errorf("pgstore: create user: %w", err) } tag, err := tx.Exec(ctx, ` insert into identities (provider, subject, user_id, email, email_verified, created_at, last_login_at) values ($1, $2, $3, $4, $5, $6, $6) on conflict (provider, subject) do nothing`, in.Provider, in.Subject, userID, nullable(in.Email), in.EmailVerified, now) if err != nil { return fmt.Errorf("pgstore: create identity: %w", err) } if tag.RowsAffected() == 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) { tag, err := s.pool.Exec(ctx, `update sessions set revoked_at = $2 where user_id = $1 and revoked_at is null`, userID, now) if err != nil { return 0, fmt.Errorf("pgstore: revoke user sessions: %w", err) } return tag.RowsAffected(), nil } // RecordLogin appends to the login journal. func (s *Store) RecordLogin(ctx context.Context, ev login.LoginEvent) error { const q = ` insert into login_events (user_id, provider, outcome, reason, ip_prefix, client, at) values ($1, $2, $3, $4, $5, $6, $7)` _, err := s.pool.Exec(ctx, q, nullable(ev.UserID), ev.Provider, ev.Outcome, ev.Reason, ev.IPPrefix, ev.Client, 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) { const q = ` select provider, outcome, reason, ip_prefix, client, at from login_events where user_id = $1 order by at desc limit $2` rows, err := s.pool.Query(ctx, q, userID, limit) if err != nil { return nil, fmt.Errorf("pgstore: recent logins: %w", err) } defer rows.Close() var out []LoginEntry for rows.Next() { var e LoginEntry if err := rows.Scan(&e.Provider, &e.Outcome, &e.Reason, &e.IPPrefix, &e.Client, &e.At); err != nil { return nil, fmt.Errorf("pgstore: scan login: %w", err) } out = append(out, e) } return out, rows.Err() } // 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 }