Release v0.1.0-preview.28: transactional account email
verify / verify (push) Successful in 4m29s

Reviewed source export adds verified TLS mail, encrypted outbox, mailbox verification and password reset protocols. Preserve public ancestry; omit local development history and operational queue. Consumer deployment and inbox delivery proof remain separate.
This commit is contained in:
2026-09-11 04:12:17 -04:00
parent ebcbbf06f5
commit 494b72fa3b
18 changed files with 3175 additions and 47 deletions
+415
View File
@@ -0,0 +1,415 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"crypto/sha256"
"database/sql"
"errors"
"strings"
"time"
"gamertan.com/web/auth"
"gamertan.com/web/authmail"
"gamertan.com/web/mail"
"gamertan.com/web/mailsqlite"
"modernc.org/sqlite"
)
// MailRepository is optional. Construction opens no new connection and performs
// no migration. The outbox shares this store's DB so account/audit/mail commits
// are atomic. Applications must check RequireMailSchema before enabling routes.
type MailRepository struct {
store *Store
queue *mailsqlite.Queue
now func() time.Time
}
func (store *Store) AccountMail(options mailsqlite.Options) (*MailRepository, *mailsqlite.Queue, error) {
if options.Now == nil {
options.Now = time.Now
}
queue, err := mailsqlite.New(store.db, options)
if err != nil {
return nil, nil, err
}
return &MailRepository{store: store, queue: queue, now: options.Now}, queue, nil
}
const MailSchemaVersion = 1
func (store *Store) RequireMailSchema(ctx context.Context) error {
var version int
if err := store.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version),0) FROM gwf_account_mail_migrations`).Scan(&version); err != nil {
return errors.New("authsqlite: explicit account mail migration required")
}
if version != MailSchemaVersion {
return errors.New("authsqlite: incompatible account mail schema")
}
return nil
}
// MigrateMail is an explicit operator migration, separate from schema 11. It
// creates no verified identities for existing users and touches no commerce data.
func (store *Store) MigrateMail(ctx context.Context) error {
if err := store.RequireCurrentSchema(ctx); err != nil {
return err
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS gwf_account_mail_migrations(version INTEGER PRIMARY KEY,applied_at INTEGER NOT NULL)`); err != nil {
return err
}
var version int
if err = tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(version),0) FROM gwf_account_mail_migrations`).Scan(&version); err != nil {
return err
}
if version > MailSchemaVersion {
return errors.New("authsqlite: newer account mail schema")
}
if version == MailSchemaVersion {
return tx.Commit()
}
if err = mailsqlite.CreateSchema(ctx, tx); err != nil {
return err
}
for _, statement := range []string{
`CREATE TABLE gwf_verified_emails(user_id TEXT PRIMARY KEY REFERENCES gwf_users(id) ON DELETE CASCADE,email_normalized TEXT NOT NULL,verified_at INTEGER NOT NULL)`,
`CREATE TABLE gwf_account_mail_requests(id TEXT NOT NULL UNIQUE,user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE,purpose TEXT NOT NULL CHECK(purpose IN ('verify','change','reset')),email TEXT NOT NULL,new_email TEXT NOT NULL,profile_revision INTEGER NOT NULL,credential_digest BLOB NOT NULL CHECK(length(credential_digest)=32),session_digest BLOB NOT NULL CHECK(length(session_digest)=32),new_digest BLOB NOT NULL UNIQUE CHECK(length(new_digest)=32),old_digest BLOB UNIQUE,new_confirmed INTEGER NOT NULL DEFAULT 0 CHECK(new_confirmed IN (0,1)),old_confirmed INTEGER NOT NULL DEFAULT 0 CHECK(old_confirmed IN (0,1)),created_at INTEGER NOT NULL,expires_at INTEGER NOT NULL CHECK(expires_at>created_at),PRIMARY KEY(user_id,purpose),CHECK(old_digest IS NULL OR length(old_digest)=32))`,
`CREATE INDEX gwf_account_mail_requests_expiry ON gwf_account_mail_requests(expires_at)`,
`CREATE TABLE gwf_account_mail_limits(user_id TEXT PRIMARY KEY REFERENCES gwf_users(id) ON DELETE CASCADE,window_started INTEGER NOT NULL,request_count INTEGER NOT NULL,last_requested INTEGER NOT NULL)`,
} {
if _, err = tx.ExecContext(ctx, statement); err != nil {
return err
}
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_account_mail_migrations(version,applied_at) VALUES(?,?)`, MailSchemaVersion, time.Now().UTC().Unix()); err != nil {
return err
}
return tx.Commit()
}
const mailSubjectSelect = `SELECT u.id,u.email,c.password_hash,u.profile_revision,EXISTS(SELECT 1 FROM gwf_verified_emails v WHERE v.user_id=u.id AND v.email_normalized=u.email_normalized)
FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.status='active' AND u.registration_pending=0`
func scanMailSubject(row interface{ Scan(...any) error }) (authmail.Subject, error) {
var subject authmail.Subject
err := row.Scan(&subject.UserID, &subject.Email, &subject.PasswordHash, &subject.Revision, &subject.Verified)
if errors.Is(err, sql.ErrNoRows) {
return subject, authmail.ErrUnavailable
}
return subject, err
}
func (repo *MailRepository) OwnSubject(ctx context.Context, session [32]byte, now time.Time) (authmail.Subject, error) {
if zeroDigest(session) || now.IsZero() {
return authmail.Subject{}, authmail.ErrUnavailable
}
return scanMailSubject(repo.store.db.QueryRowContext(ctx, mailSubjectSelect+` AND u.password_change_required=0 AND EXISTS(SELECT 1 FROM gwf_auth_sessions s WHERE s.user_id=u.id AND s.token_hash=? AND s.expires_at>?)`, session[:], now.Unix()))
}
func (repo *MailRepository) ResetSubject(ctx context.Context, email string, now time.Time) (authmail.Subject, error) {
value, err := authmail.NormalizeEmail(email)
if err != nil || value != email || now.IsZero() {
return authmail.Subject{}, authmail.ErrUnavailable
}
return scanMailSubject(repo.store.db.QueryRowContext(ctx, mailSubjectSelect+` AND u.email_normalized=? AND EXISTS(SELECT 1 FROM gwf_verified_emails v WHERE v.user_id=u.id AND v.email_normalized=u.email_normalized)`, email))
}
func mailAuditValid(audit auth.AuditEvent, request authmail.Request, suffix string) bool {
actor := request.UserID
if request.Purpose == authmail.Reset {
actor = ""
}
return validAuditEvent(audit) && audit.ActorUserID == actor && audit.Action == "auth.mail."+string(request.Purpose)+suffix && audit.ResourceType == "user" && audit.ResourceID == request.UserID
}
func mailRequestValid(request authmail.Request) bool {
email, err := authmail.NormalizeEmail(request.Email)
if err != nil || email != request.Email || !opaqueID(request.ID) || !opaqueID(request.UserID) || request.Revision < 1 || zeroDigest(request.NewDigest) || zeroDigest(request.CredentialDigest) || request.CreatedAt.IsZero() || request.ExpiresAt.Sub(request.CreatedAt) != authmail.Lifetime {
return false
}
if request.Purpose == authmail.Change {
value, err := authmail.NormalizeEmail(request.NewEmail)
return err == nil && value == request.NewEmail && value != email && !zeroDigest(request.OldDigest) && request.OldDigest != request.NewDigest && !zeroDigest(request.SessionDigest)
}
return request.NewEmail == "" && zeroDigest(request.OldDigest) && (request.Purpose == authmail.Verify && !zeroDigest(request.SessionDigest) || request.Purpose == authmail.Reset && zeroDigest(request.SessionDigest))
}
func mailSubjectMatches(subject authmail.Subject, request authmail.Request) bool {
return subject.UserID == request.UserID && normalize(subject.Email) == request.Email && subject.Revision == request.Revision && sha256.Sum256([]byte(subject.PasswordHash)) == request.CredentialDigest && (request.Purpose != authmail.Reset || subject.Verified)
}
func mailSessionCurrent(ctx context.Context, tx *sql.Tx, request authmail.Request, now time.Time) error {
if request.Purpose == authmail.Reset {
return nil
}
var valid int
err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_auth_sessions s JOIN gwf_users u ON u.id=s.user_id WHERE s.user_id=? AND s.token_hash=? AND s.expires_at>? AND u.password_change_required=0`, request.UserID, request.SessionDigest[:], now.Unix()).Scan(&valid)
if err != nil {
return err
}
if valid != 1 {
return authmail.ErrUnavailable
}
return nil
}
func (repo *MailRepository) Issue(ctx context.Context, request authmail.Request, messages []mail.Message, audit auth.AuditEvent) error {
if !mailRequestValid(request) || !mailAuditValid(audit, request, ".request") || !audit.CreatedAt.Equal(request.CreatedAt) {
return authmail.ErrInvalid
}
target := request.Email
if request.Purpose == authmail.Change {
target = request.NewEmail
}
if len(messages) != 1 && request.Purpose != authmail.Change || request.Purpose == authmail.Change && len(messages) != 2 {
return authmail.ErrInvalid
}
if messages[0].To != target || messages[0].Validate() != nil || !messages[0].CreatedAt.Equal(request.CreatedAt) {
return authmail.ErrInvalid
}
if len(messages) == 2 && (messages[1].To != request.Email || messages[1].Validate() != nil || messages[0].ID == messages[1].ID || !messages[1].CreatedAt.Equal(request.CreatedAt)) {
return authmail.ErrInvalid
}
tx, err := repo.store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Acquire the writer lock before all identity, uniqueness and rate checks.
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET updated_at=updated_at WHERE id=?`, request.UserID); err != nil {
return err
}
current := repo.now().UTC()
if !request.ExpiresAt.After(current) || request.CreatedAt.After(current.Add(time.Minute)) || request.CreatedAt.Before(current.Add(-time.Minute)) {
return authmail.ErrUnavailable
}
subject, err := scanMailSubject(tx.QueryRowContext(ctx, mailSubjectSelect+` AND u.id=?`, request.UserID))
if err != nil {
return err
}
if !mailSubjectMatches(subject, request) {
return authmail.ErrUnavailable
}
if err = mailSessionCurrent(ctx, tx, request, current); err != nil {
return err
}
if request.Purpose == authmail.Verify && subject.Verified {
return authmail.ErrUnavailable
}
if request.Purpose == authmail.Change {
var collision int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_users WHERE email_normalized=?`, request.NewEmail).Scan(&collision); err != nil {
return err
}
if collision != 0 {
return authmail.ErrAddressUnavailable
}
}
var start, last int64
var count int
err = tx.QueryRowContext(ctx, `SELECT window_started,request_count,last_requested FROM gwf_account_mail_limits WHERE user_id=?`, request.UserID).Scan(&start, &count, &last)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
now := current.Unix()
if last > now-60 || start > now-3600 && count >= 5 {
return authmail.ErrLimited
}
if start <= now-3600 {
start, count = now, 0
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_account_mail_limits(user_id,window_started,request_count,last_requested) VALUES(?,?,?,?) ON CONFLICT(user_id) DO UPDATE SET window_started=excluded.window_started,request_count=excluded.request_count,last_requested=excluded.last_requested`, request.UserID, start, count+1, now); err != nil {
return err
}
var old []byte
if request.Purpose == authmail.Change {
old = request.OldDigest[:]
}
// Both token columns share one logical namespace. Detect even an entropy
// failure that collides with the other confirmation leg before inserting.
var tokenCollision int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_account_mail_requests WHERE new_digest IN (?,?) OR old_digest IN (?,?)`, request.NewDigest[:], old, request.NewDigest[:], old).Scan(&tokenCollision); err != nil {
return err
}
if tokenCollision != 0 {
return authmail.ErrInvalid
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_account_mail_requests(id,user_id,purpose,email,new_email,profile_revision,credential_digest,session_digest,new_digest,old_digest,created_at,expires_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(user_id,purpose) DO UPDATE SET id=excluded.id,email=excluded.email,new_email=excluded.new_email,profile_revision=excluded.profile_revision,credential_digest=excluded.credential_digest,session_digest=excluded.session_digest,new_digest=excluded.new_digest,old_digest=excluded.old_digest,new_confirmed=0,old_confirmed=0,created_at=excluded.created_at,expires_at=excluded.expires_at`, request.ID, request.UserID, request.Purpose, request.Email, request.NewEmail, request.Revision, request.CredentialDigest[:], request.SessionDigest[:], request.NewDigest[:], old, request.CreatedAt.Unix(), request.ExpiresAt.Unix()); err != nil {
return err
}
for _, message := range messages {
if err = repo.queue.EnqueueTx(ctx, tx, message, request.ExpiresAt); err != nil {
return err
}
}
if err = appendAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
type mailQuerier interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}
func pendingMail(ctx context.Context, query mailQuerier, digest [32]byte, now time.Time) (authmail.Pending, error) {
var pending authmail.Pending
if zeroDigest(digest) || now.IsZero() {
return pending, authmail.ErrUnavailable
}
request := &pending.Request
var credential, session, next, old []byte
var created, expires int64
err := query.QueryRowContext(ctx, `SELECT id,user_id,purpose,email,new_email,profile_revision,credential_digest,session_digest,new_digest,old_digest,created_at,expires_at FROM gwf_account_mail_requests WHERE expires_at>? AND ((new_digest=? AND new_confirmed=0) OR (old_digest=? AND old_confirmed=0))`, now.Unix(), digest[:], digest[:]).Scan(&request.ID, &request.UserID, &request.Purpose, &request.Email, &request.NewEmail, &request.Revision, &credential, &session, &next, &old, &created, &expires)
if errors.Is(err, sql.ErrNoRows) {
return pending, authmail.ErrUnavailable
}
if err != nil {
return pending, err
}
copy(request.CredentialDigest[:], credential)
copy(request.SessionDigest[:], session)
copy(request.NewDigest[:], next)
copy(request.OldDigest[:], old)
request.CreatedAt, request.ExpiresAt = time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC()
if !mailRequestValid(*request) {
return pending, authmail.ErrUnavailable
}
pending.OldToken = digest == request.OldDigest
pending.Subject, err = scanMailSubject(query.QueryRowContext(ctx, mailSubjectSelect+` AND u.id=?`, request.UserID))
if err != nil {
return pending, err
}
if !mailSubjectMatches(pending.Subject, *request) {
return pending, authmail.ErrUnavailable
}
if request.Purpose != authmail.Reset {
var valid int
err = query.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_auth_sessions s JOIN gwf_users u ON u.id=s.user_id WHERE s.user_id=? AND s.token_hash=? AND s.expires_at>? AND u.password_change_required=0`, request.UserID, request.SessionDigest[:], now.Unix()).Scan(&valid)
if err != nil {
return pending, err
}
if valid != 1 {
return pending, authmail.ErrUnavailable
}
}
return pending, nil
}
func (repo *MailRepository) Pending(ctx context.Context, digest [32]byte, now time.Time) (authmail.Pending, error) {
return pendingMail(ctx, repo.store.db, digest, now)
}
func (repo *MailRepository) Complete(ctx context.Context, digest [32]byte, requestID, newHash string, notices []mail.Message, audit auth.AuditEvent) (bool, error) {
if zeroDigest(digest) || !opaqueID(requestID) || !validAuditEvent(audit) {
return false, authmail.ErrInvalid
}
tx, err := repo.store.db.BeginTx(ctx, nil)
if err != nil {
return false, err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `UPDATE gwf_account_mail_requests SET expires_at=expires_at WHERE id=?`, requestID); err != nil {
return false, err
}
// Re-read time after acquiring the writer lock, not before password hashing
// or a database wait. A just-expired token must not complete a mutation.
audit.CreatedAt = repo.now().UTC().Truncate(time.Second)
pending, err := pendingMail(ctx, tx, digest, audit.CreatedAt)
if err != nil {
return false, err
}
request := pending.Request
if request.ID != requestID || !mailAuditValid(audit, request, ".confirm") {
return false, authmail.ErrUnavailable
}
if request.Purpose == authmail.Reset {
if !strings.HasPrefix(newHash, "$argon2id$") || len(newHash) > 1024 || newHash == pending.Subject.PasswordHash || len(notices) != 1 || notices[0].To != request.Email {
return false, authmail.ErrInvalid
}
} else if newHash != "" || request.Purpose == authmail.Verify && len(notices) != 0 || request.Purpose == authmail.Change && (len(notices) != 2 || notices[0].To != request.Email || notices[1].To != request.NewEmail) {
return false, authmail.ErrInvalid
}
for _, message := range notices {
if message.Validate() != nil {
return false, authmail.ErrInvalid
}
}
column := "new_confirmed"
if pending.OldToken {
column = "old_confirmed"
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_account_mail_requests SET `+column+`=1 WHERE id=?`, requestID); err != nil {
return false, err
}
if request.Purpose == authmail.Change {
var ready bool
if err = tx.QueryRowContext(ctx, `SELECT new_confirmed=1 AND old_confirmed=1 FROM gwf_account_mail_requests WHERE id=?`, requestID).Scan(&ready); err != nil {
return false, err
}
if !ready {
if err = appendAudit(ctx, tx, audit); err != nil {
return false, err
}
return false, tx.Commit()
}
result, err := tx.ExecContext(ctx, `UPDATE gwf_users SET email=?,email_normalized=?,profile_revision=profile_revision+1,updated_at=MAX(updated_at,?) WHERE id=? AND profile_revision<9223372036854775807`, request.NewEmail, request.NewEmail, audit.CreatedAt.Unix(), request.UserID)
if err != nil {
var constraint *sqlite.Error
if errors.As(err, &constraint) && constraint.Code() == 2067 {
return false, authmail.ErrAddressUnavailable
}
return false, err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return false, authmail.ErrUnavailable
}
// Invitations issued to the previous identity do not migrate to another
// mailbox/account. Existing memberships remain bound to immutable user ID.
if _, err = tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET revoked_at=? WHERE email_normalized=? AND used_at IS NULL AND revoked_at IS NULL`, audit.CreatedAt.Unix(), request.Email); err != nil {
return false, err
}
}
if request.Purpose != authmail.Reset {
email := request.Email
if request.Purpose == authmail.Change {
email = request.NewEmail
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_verified_emails(user_id,email_normalized,verified_at) VALUES(?,?,?) ON CONFLICT(user_id) DO UPDATE SET email_normalized=excluded.email_normalized,verified_at=excluded.verified_at`, request.UserID, email, audit.CreatedAt.Unix()); err != nil {
return false, err
}
} else {
if _, err = tx.ExecContext(ctx, `UPDATE gwf_password_credentials SET password_hash=?,changed_at=? WHERE user_id=?`, newHash, audit.CreatedAt.Unix(), request.UserID); err != nil {
return false, err
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET password_change_required=0,updated_at=MAX(updated_at,?) WHERE id=?`, audit.CreatedAt.Unix(), request.UserID); err != nil {
return false, err
}
}
if request.Purpose != authmail.Verify {
// Keep enrolled passkeys and recovery-code digests. Revoke only sessions,
// in-flight ceremonies, enrollment/recovery grants and pending mail links.
for _, table := range []string{"gwf_auth_sessions", "gwf_passkey_ceremonies", "gwf_passkey_enrollment_tokens", "gwf_recovery_grants", "gwf_assisted_recovery_grants", "gwf_account_mail_requests"} {
if _, err = tx.ExecContext(ctx, `DELETE FROM `+table+` WHERE user_id=?`, request.UserID); err != nil {
return false, err
}
}
} else if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_account_mail_requests WHERE id=?`, requestID); err != nil {
return false, err
}
for _, message := range notices {
if err = repo.queue.EnqueueTx(ctx, tx, message, audit.CreatedAt.Add(24*time.Hour)); err != nil {
return false, err
}
}
if err = appendAudit(ctx, tx, audit); err != nil {
return false, err
}
return true, tx.Commit()
}
+592
View File
@@ -0,0 +1,592 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"bytes"
"context"
"crypto/sha256"
"errors"
"net/url"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"gamertan.com/web/auth"
"gamertan.com/web/authmail"
"gamertan.com/web/mail"
"gamertan.com/web/mailsqlite"
)
const mailPassword = "correct horse battery staple"
var accountMailHash = sync.OnceValues(func() (string, error) { return auth.HashPassword(mailPassword) })
type mailClock struct{ unix atomic.Int64 }
func (clock *mailClock) now() time.Time { return time.Unix(clock.unix.Load(), 0).UTC() }
func (clock *mailClock) advance(duration time.Duration) {
clock.unix.Add(int64(duration / time.Second))
}
type mailFixture struct {
store *Store
repo *MailRepository
queue *mailsqlite.Queue
service *authmail.Service
clock *mailClock
user auth.User
session auth.Session
path string
}
func mailConfig(clock *mailClock) authmail.Config {
return authmail.Config{Origin: "https://accounts.example.test", SiteName: "Example Site", ConfirmPath: "/account/email/confirm/", ResetPath: "/reset-password/", SecurityPath: "/account/security/", Now: clock.now}
}
func accountMailFixture(t *testing.T, capacity int) mailFixture {
t.Helper()
clock := &mailClock{}
clock.unix.Store(time.Date(2026, 9, 11, 8, 0, 0, 0, time.UTC).Unix())
path := filepath.Join(t.TempDir(), "identity.sqlite")
store, err := Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
if err = store.RequireMailSchema(t.Context()); err == nil {
t.Fatal("mail schema silently installed")
}
if err = store.MigrateMail(t.Context()); err != nil {
t.Fatal(err)
}
if err = store.MigrateMail(t.Context()); err != nil {
t.Fatal(err)
}
if err = store.RequireMailSchema(t.Context()); err != nil {
t.Fatal(err)
}
user := auth.User{ID: "mail_user_fixture", Username: "mail.reader", Email: "reader@example.test", DisplayName: "Reader", Status: "active", CreatedAt: clock.now(), UpdatedAt: clock.now()}
hash, err := accountMailHash()
if err != nil {
t.Fatal(err)
}
if err = store.CreateUser(t.Context(), user, hash); err != nil {
t.Fatal(err)
}
session := auth.Session{UserID: user.ID, Digest: sha256.Sum256([]byte("acting-mail-session")), CreatedAt: clock.now(), ExpiresAt: clock.now().Add(24 * time.Hour), LastSeenAt: clock.now()}
if err = store.CreateSession(t.Context(), session); err != nil {
t.Fatal(err)
}
repo, queue, err := store.AccountMail(mailsqlite.Options{EncryptionKey: bytes.Repeat([]byte{31}, 32), MaxPending: capacity, Now: clock.now})
if err != nil {
t.Fatal(err)
}
service, err := authmail.New(repo, mailConfig(clock))
if err != nil {
t.Fatal(err)
}
return mailFixture{store, repo, queue, service, clock, user, session, path}
}
type collectMail struct{ messages []mail.Message }
func (collector *collectMail) Send(_ context.Context, message mail.Message) error {
collector.messages = append(collector.messages, message)
return nil
}
func (fixture mailFixture) drain(t *testing.T) []mail.Message {
t.Helper()
collector := &collectMail{}
for range 10 {
record, work, err := fixture.queue.ProcessOne(t.Context(), collector)
if err != nil {
t.Fatal(err)
}
if !work {
return collector.messages
}
if record.State != "accepted" {
t.Fatalf("outbox state=%s stage=%s", record.State, record.FailureStage)
}
}
t.Fatal("unbounded fixture queue")
return nil
}
func mailToken(t *testing.T, message mail.Message) string {
t.Helper()
for _, part := range strings.Fields(message.Text) {
parsed, err := url.Parse(part)
if err == nil && parsed.Scheme == "https" && parsed.Query().Get("token") != "" {
if parsed.Host != "accounts.example.test" {
t.Fatal("untrusted action origin")
}
return parsed.Query().Get("token")
}
}
t.Fatal("no action token in fixture mail")
return ""
}
func (fixture mailFixture) verify(t *testing.T) {
t.Helper()
if err := fixture.service.RequestVerification(t.Context(), fixture.session.Digest); err != nil {
t.Fatal(err)
}
messages := fixture.drain(t)
if len(messages) != 1 {
t.Fatalf("verification messages=%d", len(messages))
}
if complete, err := fixture.service.Confirm(t.Context(), mailToken(t, messages[0])); err != nil || !complete {
t.Fatalf("verify: %v %v", complete, err)
}
fixture.clock.advance(time.Minute)
}
func TestMailVerificationResetAndPreservedFactors(t *testing.T) {
f := accountMailFixture(t, 0)
if verified, err := f.service.Status(t.Context(), f.session.Digest); err != nil || verified {
t.Fatalf("legacy auto-verified: %v %v", verified, err)
}
for _, email := range []string{f.user.Email, "unknown@example.test", "not an address"} {
if err := f.service.RequestReset(t.Context(), email); err != nil {
t.Fatal(err)
}
}
if got := f.drain(t); len(got) != 0 {
t.Fatal("unverified/unknown address received reset")
}
if err := f.service.RequestVerification(t.Context(), f.session.Digest); err != nil {
t.Fatal(err)
}
messages := f.drain(t)
if len(messages) != 1 || messages[0].To != f.user.Email {
t.Fatal("verification recipient")
}
token := mailToken(t, messages[0])
for range 2 {
if purpose, err := f.service.Inspect(t.Context(), token); err != nil || purpose != authmail.Verify {
t.Fatalf("inspect: %s %v", purpose, err)
}
}
if verified, _ := f.service.Status(t.Context(), f.session.Digest); verified {
t.Fatal("read-only inspection mutated identity")
}
if err := f.service.ResetPassword(t.Context(), token, "a different safe password"); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("verify token used for reset: %v", err)
}
if completed, err := f.service.Confirm(t.Context(), token); err != nil || !completed {
t.Fatalf("confirm: %v %v", completed, err)
}
if _, err := f.service.Confirm(t.Context(), token); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("token replay: %v", err)
}
if verified, err := f.service.Status(t.Context(), f.session.Digest); err != nil || !verified {
t.Fatalf("not verified: %v %v", verified, err)
}
for _, statement := range []string{
`INSERT INTO gwf_passkey_credentials(credential_id,user_id,label,credential_json,created_at) VALUES(X'010203','mail_user_fixture','Fixture',X'7B7D',1)`,
`INSERT INTO gwf_recovery_codes(user_id,code_hash,created_at) VALUES('mail_user_fixture',X'1234',1)`,
`INSERT INTO gwf_recovery_grants(token_hash,user_id,created_at,expires_at) VALUES(X'1234','mail_user_fixture',1,9999999999)`,
`INSERT INTO gwf_passkey_enrollment_tokens(token_hash,user_id,created_at,expires_at) VALUES(X'1234','mail_user_fixture',1,9999999999)`,
} {
if _, err := f.store.db.Exec(statement); err != nil {
t.Fatal(err)
}
}
f.clock.advance(time.Minute)
if err := f.service.RequestReset(t.Context(), strings.ToUpper(f.user.Email)); err != nil {
t.Fatal(err)
}
if err := f.service.RequestReset(t.Context(), f.user.Email); err != nil {
t.Fatal("rate limit disclosed account", err)
}
messages = f.drain(t)
if len(messages) != 1 {
t.Fatalf("reset requests=%d", len(messages))
}
token = mailToken(t, messages[0])
if _, err := f.service.Confirm(t.Context(), token); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("reset token confirmed: %v", err)
}
if err := f.service.ResetPassword(t.Context(), token, mailPassword); !errors.Is(err, auth.ErrPasswordUnchanged) {
t.Fatalf("password reuse: %v", err)
}
if err := f.service.ResetPassword(t.Context(), token, "a different safe password"); err != nil {
t.Fatal(err)
}
user, hash, err := f.store.CredentialByUserID(t.Context(), f.user.ID)
if err != nil || user.ID != f.user.ID || user.Email != f.user.Email || !auth.VerifyPassword(hash, "a different safe password") || auth.VerifyPassword(hash, mailPassword) {
t.Fatal("incorrect reset result", err)
}
if _, _, err = f.store.PrincipalBySession(t.Context(), f.session.Digest, f.clock.now()); !errors.Is(err, auth.ErrSessionNotFound) {
t.Fatalf("session survived: %v", err)
}
for table, want := range map[string]int{"gwf_passkey_credentials": 1, "gwf_recovery_codes": 1, "gwf_recovery_grants": 0, "gwf_passkey_enrollment_tokens": 0, "gwf_account_mail_requests": 0} {
var count int
if err = f.store.db.QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE user_id=?`, f.user.ID).Scan(&count); err != nil || count != want {
t.Fatalf("%s count=%d want=%d err=%v", table, count, want, err)
}
}
if err = f.service.ResetPassword(t.Context(), token, "third different safe password"); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("reset replay: %v", err)
}
messages = f.drain(t)
if len(messages) != 1 || !strings.Contains(messages[0].Subject, "password was changed") || strings.Contains(messages[0].Text, "token=") {
t.Fatal("password-change notice missing or carries token")
}
}
func TestMailAddressChangeBothMailboxesAndStableOwnership(t *testing.T) {
for _, oldFirst := range []bool{false, true} {
t.Run(map[bool]string{false: "new-first", true: "old-first"}[oldFirst], func(t *testing.T) {
f := accountMailFixture(t, 0)
if err := f.service.RequestChange(t.Context(), f.session.Digest, "wrong password", "new@example.test"); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("unauthenticated change: %v", err)
}
if err := f.service.RequestChange(t.Context(), f.session.Digest, mailPassword, "NEW@example.test"); err != nil {
t.Fatal(err)
}
for _, statement := range []string{
`CREATE TABLE preserved_purchase(id TEXT PRIMARY KEY,user_id TEXT,old_email TEXT)`,
`INSERT INTO preserved_purchase VALUES('order_fixture','mail_user_fixture','reader@example.test')`,
`INSERT INTO gwf_organizations(id,slug,name,personal,created_at,updated_at) VALUES('family_fixture','family-fixture','Family',0,1,1)`,
`INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES('family_fixture','mail_user_fixture','active',1)`,
`INSERT INTO gwf_organization_invitations(token_hash,id,organization_id,email_normalized,invited_by_user_id,created_at,expires_at) VALUES(X'0101','old_invitation','family_fixture','reader@example.test','mail_user_fixture',1,9999999999)`,
`INSERT INTO gwf_organization_invitations(token_hash,id,organization_id,email_normalized,invited_by_user_id,created_at,expires_at) VALUES(X'0202','new_invitation','family_fixture','new@example.test','mail_user_fixture',1,9999999999)`,
} {
if _, err := f.store.db.Exec(statement); err != nil {
t.Fatal(err)
}
}
messages := f.drain(t)
if len(messages) != 2 {
t.Fatalf("change messages=%d", len(messages))
}
var oldToken, newToken string
for _, message := range messages {
if message.To == f.user.Email {
oldToken = mailToken(t, message)
} else if message.To == "new@example.test" {
newToken = mailToken(t, message)
} else {
t.Fatal("wrong mailbox")
}
}
first, second := newToken, oldToken
if oldFirst {
first, second = oldToken, newToken
}
if done, err := f.service.Confirm(t.Context(), first); err != nil || done {
t.Fatalf("first confirmation: %v %v", done, err)
}
user, _, err := f.store.CredentialByUserID(t.Context(), f.user.ID)
if err != nil || user.Email != f.user.Email {
t.Fatal("address changed with one mailbox")
}
if got := f.drain(t); len(got) != 0 {
t.Fatal("premature change notice")
}
if _, err = f.service.Confirm(t.Context(), first); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("first-leg replay: %v", err)
}
if done, err := f.service.Confirm(t.Context(), second); err != nil || !done {
t.Fatalf("second confirmation: %v %v", done, err)
}
user, _, err = f.store.CredentialByUserID(t.Context(), f.user.ID)
if err != nil || user.Email != "new@example.test" || user.ID != f.user.ID {
t.Fatal("canonical identity changed incorrectly")
}
if _, _, err = f.store.CredentialByIdentifier(t.Context(), f.user.Email); !errors.Is(err, auth.ErrUserNotFound) {
t.Fatalf("old login address survived: %v", err)
}
if subject, err := f.repo.ResetSubject(t.Context(), "new@example.test", f.clock.now()); err != nil || !subject.Verified {
t.Fatal("new mailbox not verified", err)
}
var value string
if err = f.store.db.QueryRow(`SELECT user_id||':'||old_email FROM preserved_purchase`).Scan(&value); err != nil || value != f.user.ID+":"+f.user.Email {
t.Fatal("historical purchase changed")
}
var count int
if err = f.store.db.QueryRow(`SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, f.user.ID).Scan(&count); err != nil || count != 1 {
t.Fatal("membership changed")
}
if err = f.store.db.QueryRow(`SELECT COUNT(*) FROM gwf_organization_invitations WHERE id='old_invitation' AND revoked_at IS NOT NULL`).Scan(&count); err != nil || count != 1 {
t.Fatal("old invitation retained")
}
if err = f.store.db.QueryRow(`SELECT COUNT(*) FROM gwf_organization_invitations WHERE id='new_invitation' AND revoked_at IS NULL AND used_at IS NULL`).Scan(&count); err != nil || count != 1 {
t.Fatal("new invitation granted or rewritten")
}
if _, _, err = f.store.PrincipalBySession(t.Context(), f.session.Digest, f.clock.now()); !errors.Is(err, auth.ErrSessionNotFound) {
t.Fatal("change did not sign out sessions")
}
if got := f.drain(t); len(got) != 2 {
t.Fatalf("change notices=%d", len(got))
}
})
}
}
func TestMailTokenInvalidation(t *testing.T) {
for _, test := range []struct {
name, statement string
advance time.Duration
}{
{"expiry", "", authmail.Lifetime},
{"password-changed", `UPDATE gwf_password_credentials SET password_hash='new-hash'`, 0},
{"email-changed", `UPDATE gwf_users SET email='elsewhere@example.test',email_normalized='elsewhere@example.test'`, 0},
{"profile-revision", `UPDATE gwf_users SET profile_revision=profile_revision+1`, 0},
{"suspended", `UPDATE gwf_users SET status='suspended'`, 0},
{"pending", `UPDATE gwf_users SET registration_pending=1`, 0},
{"session-revoked", `DELETE FROM gwf_auth_sessions`, 0},
{"forced-password-change", `UPDATE gwf_users SET password_change_required=1`, 0},
} {
t.Run(test.name, func(t *testing.T) {
f := accountMailFixture(t, 0)
if err := f.service.RequestVerification(t.Context(), f.session.Digest); err != nil {
t.Fatal(err)
}
token := mailToken(t, f.drain(t)[0])
if test.statement != "" {
if _, err := f.store.db.Exec(test.statement); err != nil {
t.Fatal(err)
}
}
f.clock.advance(test.advance)
if _, err := f.service.Confirm(t.Context(), token); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("invalidated token accepted: %v", err)
}
var count int
if err := f.store.db.QueryRow(`SELECT COUNT(*) FROM gwf_verified_emails`).Scan(&count); err != nil || count != 0 {
t.Fatal("invalidated verification wrote state")
}
})
}
}
func TestMailAuditAndOutboxRollback(t *testing.T) {
f := accountMailFixture(t, 1)
if _, err := f.store.db.Exec(`CREATE TRIGGER reject_mail_audit BEFORE INSERT ON gwf_audit_events WHEN NEW.action LIKE 'auth.mail.%' BEGIN SELECT RAISE(ABORT,'fixture audit failure'); END`); err != nil {
t.Fatal(err)
}
if err := f.service.RequestVerification(t.Context(), f.session.Digest); err == nil {
t.Fatal("audit failure ignored")
}
for _, table := range []string{"gwf_account_mail_requests", "gwf_account_mail_limits", "gwf_mail_outbox"} {
var count int
if err := f.store.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&count); err != nil || count != 0 {
t.Fatalf("%s not rolled back", table)
}
}
if _, err := f.store.db.Exec(`DROP TRIGGER reject_mail_audit`); err != nil {
t.Fatal(err)
}
f.verify(t)
if err := f.service.RequestReset(t.Context(), f.user.Email); err != nil {
t.Fatal(err)
}
token := mailToken(t, f.drain(t)[0])
if err := f.queue.Enqueue(t.Context(), mail.Message{ID: "mail_capacity_fixture", To: "other@example.test", Subject: "Fixture", Text: "Unrelated message", CreatedAt: f.clock.now()}, f.clock.now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
if err := f.service.ResetPassword(t.Context(), token, "a different safe password"); !errors.Is(err, mailsqlite.ErrFull) {
t.Fatalf("outbox failure: %v", err)
}
_, hash, err := f.store.CredentialByUserID(t.Context(), f.user.ID)
if err != nil || !auth.VerifyPassword(hash, mailPassword) {
t.Fatal("password changed without committed notification")
}
if _, err = f.service.Inspect(t.Context(), token); err != nil {
t.Fatal("token lost on rollback", err)
}
if _, _, err = f.store.PrincipalBySession(t.Context(), f.session.Digest, f.clock.now()); err != nil {
t.Fatal("session lost on rollback", err)
}
f.drain(t)
if err = f.service.ResetPassword(t.Context(), token, "a different safe password"); err != nil {
t.Fatal("retry after queue recovery", err)
}
}
func TestMailConcurrentConsumeAndRateLimit(t *testing.T) {
f := accountMailFixture(t, 0)
f.verify(t)
if err := f.service.RequestReset(t.Context(), f.user.Email); err != nil {
t.Fatal(err)
}
token := mailToken(t, f.drain(t)[0])
var group sync.WaitGroup
var accepted, rejected atomic.Int32
for range 2 {
group.Go(func() {
err := f.service.ResetPassword(context.Background(), token, "a different safe password")
if err == nil {
accepted.Add(1)
} else if errors.Is(err, authmail.ErrUnavailable) {
rejected.Add(1)
} else {
t.Errorf("concurrent consume: %v", err)
}
})
}
group.Wait()
if accepted.Load() != 1 || rejected.Load() != 1 {
t.Fatalf("accepted=%d rejected=%d", accepted.Load(), rejected.Load())
}
g := accountMailFixture(t, 0)
var firstToken string
for request := range 5 {
if err := g.service.RequestVerification(t.Context(), g.session.Digest); err != nil {
t.Fatal(err)
}
messages := g.drain(t)
if request == 0 {
firstToken = mailToken(t, messages[0])
}
if err := g.service.RequestVerification(t.Context(), g.session.Digest); !errors.Is(err, authmail.ErrLimited) {
t.Fatalf("burst limit: %v", err)
}
g.clock.advance(time.Minute)
}
if err := g.service.RequestVerification(t.Context(), g.session.Digest); !errors.Is(err, authmail.ErrLimited) {
t.Fatalf("hour limit: %v", err)
}
if _, err := g.service.Inspect(t.Context(), firstToken); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatal("replaced link survived")
}
g.clock.advance(time.Hour)
if err := g.service.RequestVerification(t.Context(), g.session.Digest); err != nil {
t.Fatal("rate window did not recover", err)
}
}
func TestMailRestartAndAddressCollision(t *testing.T) {
f := accountMailFixture(t, 0)
if err := f.service.RequestChange(t.Context(), f.session.Digest, mailPassword, "new@example.test"); err != nil {
t.Fatal(err)
}
messages := f.drain(t)
first, second := mailToken(t, messages[0]), mailToken(t, messages[1])
if _, err := f.service.Confirm(t.Context(), first); err != nil {
t.Fatal(err)
}
other := f.user
other.ID = "another_mail_user"
other.Username = "another.reader"
other.Email = "new@example.test"
hash, _ := accountMailHash()
if err := f.store.CreateUser(t.Context(), other, hash); err != nil {
t.Fatal(err)
}
reopened, err := OpenWithOptions(f.path, OpenOptions{Migrate: false})
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
if err = reopened.RequireMailSchema(t.Context()); err != nil {
t.Fatal(err)
}
repo, _, err := reopened.AccountMail(mailsqlite.Options{EncryptionKey: bytes.Repeat([]byte{31}, 32), Now: f.clock.now})
if err != nil {
t.Fatal(err)
}
service, err := authmail.New(repo, mailConfig(f.clock))
if err != nil {
t.Fatal(err)
}
if _, err = service.Confirm(t.Context(), second); !errors.Is(err, authmail.ErrAddressUnavailable) {
t.Fatalf("collision not rechecked: %v", err)
}
if _, err = service.Inspect(t.Context(), second); err != nil {
t.Fatal("collision consumed token", err)
}
user, _, err := f.store.CredentialByUserID(t.Context(), f.user.ID)
if err != nil || user.Email != f.user.Email {
t.Fatal("collision overwrote canonical email")
}
var version int
if version, err = f.store.CurrentSchema(t.Context()); err != nil || version != 11 {
t.Fatalf("base schema changed: %d %v", version, err)
}
}
func TestMailCommitUsesFreshTimeAndCredentials(t *testing.T) {
for _, change := range []string{"expiry", "credential"} {
t.Run(change, func(t *testing.T) {
f := accountMailFixture(t, 0)
f.verify(t)
if err := f.service.RequestReset(t.Context(), f.user.Email); err != nil {
t.Fatal(err)
}
token := mailToken(t, f.drain(t)[0])
config := mailConfig(f.clock)
config.Compose = func(intent authmail.MailIntent) (authmail.MessageContent, error) {
if intent.Kind == "password-changed" {
if change == "expiry" {
f.clock.advance(authmail.Lifetime)
} else {
if _, err := f.store.db.Exec(`UPDATE gwf_password_credentials SET password_hash='concurrent-credential'`); err != nil {
t.Fatal(err)
}
}
}
return authmail.DefaultComposer(intent)
}
service, err := authmail.New(f.repo, config)
if err != nil {
t.Fatal(err)
}
if err = service.ResetPassword(t.Context(), token, "a different safe password"); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("stale completion: %v", err)
}
_, hash, err := f.store.CredentialByUserID(t.Context(), f.user.ID)
if err != nil || auth.VerifyPassword(hash, "a different safe password") {
t.Fatal("stale completion changed password")
}
if got := f.drain(t); len(got) != 0 {
t.Fatal("uncommitted reset sent notice")
}
})
}
}
func TestMailMigrationRollbackAndMixedCaseLegacyAddress(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "rollback.sqlite"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
if _, err = store.db.Exec(`CREATE TABLE gwf_verified_emails(conflicting_schema TEXT)`); err != nil {
t.Fatal(err)
}
if err = store.MigrateMail(t.Context()); err == nil {
t.Fatal("schema conflict ignored")
}
var count int
if err = store.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE name IN ('gwf_mail_outbox','gwf_account_mail_migrations')`).Scan(&count); err != nil || count != 0 {
t.Fatal("partial mail migration remained")
}
if err = store.RequireCurrentSchema(t.Context()); err != nil {
t.Fatal("base schema damaged", err)
}
f := accountMailFixture(t, 0)
if _, err = f.store.db.Exec(`UPDATE gwf_users SET email='READER@Example.Test'`); err != nil {
t.Fatal(err)
}
f.verify(t)
if err = f.service.RequestReset(t.Context(), "Reader@Example.Test"); err != nil {
t.Fatal(err)
}
token := mailToken(t, f.drain(t)[0])
if err = f.service.ResetPassword(t.Context(), token, "a different safe password"); err != nil {
t.Fatal("legacy address reset", err)
}
if notices := f.drain(t); len(notices) != 1 || notices[0].To != "reader@example.test" {
t.Fatal("canonical notice recipient")
}
}