NextMyDay/internal/core/auth/acceptance_test.go
Claus Lohmar 76a4a6950c feat(auth): add WebAuthn, TOTP and the enroll/login flow (SPEC-004/006)
- webauthn: the passkey ceremony service (a fake in tests)
- totp: the TOTP authenticator; the secret is AEAD-sealed at rest (A9)
- sealer: the AEAD TOTP sealer
- identities/sessions/credentials: the GUID state machine
- flow: the enroll/login screens + the passkey nudge (pre-session
  double-submit CSRF — A15), rendered inside the shell
2026-10-14 10:53:53 +00:00

578 lines
18 KiB
Go

package auth_test
import (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
)
// The Identities service satisfies the pure model's injected Resolver
// (SPEC-002 §3.1) — a compile-time proof.
var _ routing.Resolver = (*auth.IdentitiesService)(nil)
// fakeClock drives session/identity expiry deterministically.
type fakeClock struct{ now time.Time }
func (c *fakeClock) Now() time.Time { return c.now }
func (c *fakeClock) Advance(d time.Duration) { c.now = c.now.Add(d) }
type fixture struct {
t *testing.T
ctx context.Context
db *store.DB
users *users.Store
ids *auth.IdentitiesService
sessions *auth.SessionsService
clock *fakeClock
}
func newFixture(t *testing.T, opts ...auth.Option) *fixture {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
clk := &fakeClock{now: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)}
allOpts := append([]auth.Option{auth.WithClock(clk.Now)}, opts...)
aud := audit.New(db.DB)
core := auth.New(db.DB, aud, allOpts...)
return &fixture{
t: t,
ctx: context.Background(),
db: db,
users: users.New(db.DB, aud),
ids: core.Identities(),
sessions: core.Sessions(),
clock: clk,
}
}
func (f *fixture) admin() int64 {
f.t.Helper()
id, err := f.users.Create(f.ctx, 0, "admin@example.com", "Admin", users.RoleAdmin)
mustNoErr(f.t, err)
return id
}
func (f *fixture) activeIdentity(userID int64) (int64, string) {
f.t.Helper()
id, guid, err := f.ids.Mint(f.ctx, userID)
mustNoErr(f.t, err)
mustNoErr(f.t, f.ids.Activate(f.ctx, id))
return id, guid
}
func (f *fixture) auditCount() int {
f.t.Helper()
var n int
if err := f.db.QueryRow(`SELECT count(*) FROM audit_log`).Scan(&n); err != nil {
f.t.Fatalf("count audit_log: %v", err)
}
return n
}
// expectAudit asserts fn produced at least one audit row (AC-7).
func (f *fixture) expectAudit(name string, fn func()) {
f.t.Helper()
before := f.auditCount()
fn()
if after := f.auditCount(); after <= before {
f.t.Errorf("AC-7: %s wrote no audit row (before=%d after=%d)", name, before, after)
}
}
func mustNoErr(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
// AC-1: two active identities for one user is impossible — service AND index.
func TestAC1_OneActiveIdentityPerUser(t *testing.T) {
f := newFixture(t)
admin := f.admin()
director, err := f.users.Create(f.ctx, admin, "d@example.com", "D", users.RoleDirector)
mustNoErr(t, err)
f.activeIdentity(director)
// Service check: Mint refuses while a live identity exists.
if _, _, err := f.ids.Mint(f.ctx, director); !errors.Is(err, auth.ErrIdentityExists) {
t.Errorf("Mint with a live identity = %v, want ErrIdentityExists", err)
}
now := store.FormatTime(f.clock.Now())
// Index check: a hand-inserted second ACTIVE row is rejected.
if _, err := f.db.Exec(
`INSERT INTO identities (user_id, guid, status, created_at) VALUES (?, 'manual-active', 'active', ?)`,
director, now,
); err == nil {
t.Error("a second active identity was inserted, want a UNIQUE violation (AC-1 index)")
}
// Index check via the service: a hand-inserted pending row cannot be
// activated into a second active identity.
res, err := f.db.Exec(
`INSERT INTO identities (user_id, guid, status, created_at) VALUES (?, 'manual-pending', 'pending_enrollment', ?)`,
director, now,
)
mustNoErr(t, err)
pendingID, err := res.LastInsertId()
mustNoErr(t, err)
if err := f.ids.Activate(f.ctx, pendingID); err == nil {
t.Error("Activate produced a second active identity, want an error (AC-1)")
}
}
// AC-2: guid is unique and a revoked GUID is never reissued (tombstoned).
func TestAC2_GUIDUniqueAndTombstoned(t *testing.T) {
f := newFixture(t)
admin := f.admin()
id, guid1 := f.activeIdentity(admin)
if guid1 == "" || routing.IsReservedWorkflow(guid1) {
t.Fatalf("minted guid %q is empty or reserved", guid1)
}
mustNoErr(t, f.ids.Revoke(f.ctx, id))
guid2, err := f.ids.Rotate(f.ctx, admin)
mustNoErr(t, err)
if guid2 == guid1 {
t.Error("Rotate reissued the tombstoned GUID (AC-2)")
}
// The old GUID can never be re-inserted.
if _, err := f.db.Exec(
`INSERT INTO identities (user_id, guid, status, created_at) VALUES (?, ?, 'pending_enrollment', ?)`,
admin, guid1, store.FormatTime(f.clock.Now()),
); err == nil {
t.Error("a tombstoned GUID was reused, want a UNIQUE violation (AC-2)")
}
// And it resolves as revoked/tombstoned, not as a live identity.
old, err := f.ids.ByGUID(f.ctx, guid1)
mustNoErr(t, err)
if old.Status != auth.StatusRevoked {
t.Errorf("old identity status = %s, want revoked", old.Status)
}
if found, enrolled := f.ids.GUID(guid1); found || enrolled {
t.Errorf("tombstoned GUID resolved found=%v enrolled=%v, want false/false", found, enrolled)
}
}
// AC-3: an empty session id is rejected (A2).
func TestAC3_EmptySessionRejected(t *testing.T) {
f := newFixture(t)
if _, err := f.sessions.Validate(f.ctx, ""); !errors.Is(err, auth.ErrEmptySessionID) {
t.Errorf("Validate(\"\") = %v, want ErrEmptySessionID (AC-3, A2)", err)
}
if err := f.sessions.Revoke(f.ctx, ""); !errors.Is(err, auth.ErrEmptySessionID) {
t.Errorf("Revoke(\"\") = %v, want ErrEmptySessionID", err)
}
}
// AC-4: expired/revoked sessions error; RevokeAll kills every session.
func TestAC4_ExpiredRevokedAndRevokeAll(t *testing.T) {
f := newFixture(t, auth.WithSessionTTL(time.Hour))
admin := f.admin()
id, _ := f.activeIdentity(admin)
s1, err := f.sessions.Mint(f.ctx, id, "127.0.0.1", "test")
mustNoErr(t, err)
s2, err := f.sessions.Mint(f.ctx, id, "127.0.0.1", "test")
mustNoErr(t, err)
if _, err := f.sessions.Validate(f.ctx, s1.ID); err != nil {
t.Fatalf("a fresh session failed to validate: %v", err)
}
// Revoked -> error.
mustNoErr(t, f.sessions.Revoke(f.ctx, s1.ID))
if _, err := f.sessions.Validate(f.ctx, s1.ID); !errors.Is(err, auth.ErrSessionRevoked) {
t.Errorf("Validate(revoked) = %v, want ErrSessionRevoked", err)
}
// Expired -> error.
f.clock.Advance(2 * time.Hour)
if _, err := f.sessions.Validate(f.ctx, s2.ID); !errors.Is(err, auth.ErrSessionExpired) {
t.Errorf("Validate(expired) = %v, want ErrSessionExpired", err)
}
// RevokeAll kills every live session of the identity.
a, err := f.sessions.Mint(f.ctx, id, "127.0.0.1", "test")
mustNoErr(t, err)
b, err := f.sessions.Mint(f.ctx, id, "127.0.0.1", "test")
mustNoErr(t, err)
mustNoErr(t, f.sessions.RevokeAll(f.ctx, id))
for _, sid := range []string{a.ID, b.ID} {
if _, err := f.sessions.Validate(f.ctx, sid); !errors.Is(err, auth.ErrSessionRevoked) {
t.Errorf("Validate(%s) after RevokeAll = %v, want ErrSessionRevoked", sid, err)
}
}
}
// AC-5: Rotate revokes the old identity AND its sessions, and mints a new GUID.
func TestAC5_RotateRevokesOldAndMintsNew(t *testing.T) {
f := newFixture(t)
admin := f.admin()
id, guid1 := f.activeIdentity(admin)
session, err := f.sessions.Mint(f.ctx, id, "1.2.3.4", "ua")
mustNoErr(t, err)
guid2, err := f.ids.Rotate(f.ctx, admin)
mustNoErr(t, err)
if guid2 == "" || guid2 == guid1 {
t.Fatalf("Rotate guid = %q, want a fresh non-empty GUID", guid2)
}
old, err := f.ids.ByGUID(f.ctx, guid1)
mustNoErr(t, err)
if old.Status != auth.StatusRevoked || old.RevokedAt == nil {
t.Errorf("old identity = %+v, want revoked with a timestamp", old)
}
// Every session of the old identity is dead.
if _, err := f.sessions.Validate(f.ctx, session.ID); !errors.Is(err, auth.ErrSessionRevoked) {
t.Errorf("old session after Rotate = %v, want ErrSessionRevoked", err)
}
// The new identity is pending enrollment under the same user.
fresh, err := f.ids.ByGUID(f.ctx, guid2)
mustNoErr(t, err)
if fresh.Status != auth.StatusPendingEnrollment {
t.Errorf("new identity status = %s, want pending_enrollment", fresh.Status)
}
if fresh.UserID != admin {
t.Errorf("new identity user = %d, want %d (employment survives rotation)", fresh.UserID, admin)
}
}
// AC-7: every service mutation writes an audit_log row (A11).
func TestAC7_EveryMutationAudited(t *testing.T) {
f := newFixture(t)
admin := f.admin()
var director int64
f.expectAudit("users.Create", func() {
var err error
director, err = f.users.Create(f.ctx, admin, "d@example.com", "D", users.RoleDirector)
mustNoErr(t, err)
})
f.expectAudit("users.SetRole", func() {
mustNoErr(t, f.users.SetRole(f.ctx, director, users.RoleManager))
})
var id int64
f.expectAudit("identities.Mint", func() {
var err error
id, _, err = f.ids.Mint(f.ctx, director)
mustNoErr(t, err)
})
f.expectAudit("identities.Activate", func() {
mustNoErr(t, f.ids.Activate(f.ctx, id))
})
var sess auth.Session
f.expectAudit("sessions.Mint", func() {
var err error
sess, err = f.sessions.Mint(f.ctx, id, "1.2.3.4", "ua")
mustNoErr(t, err)
})
f.expectAudit("sessions.Rotate", func() {
_, err := f.sessions.Rotate(f.ctx, sess.ID)
mustNoErr(t, err)
})
sess2, err := f.sessions.Mint(f.ctx, id, "1.2.3.4", "ua")
mustNoErr(t, err)
f.expectAudit("sessions.Revoke", func() {
mustNoErr(t, f.sessions.Revoke(f.ctx, sess2.ID))
})
_, err = f.sessions.Mint(f.ctx, id, "1.2.3.4", "ua")
mustNoErr(t, err)
f.expectAudit("sessions.RevokeAll", func() {
mustNoErr(t, f.sessions.RevokeAll(f.ctx, id))
})
f.expectAudit("identities.Revoke", func() {
mustNoErr(t, f.ids.Revoke(f.ctx, id))
})
f.expectAudit("identities.Rotate", func() {
_, err := f.ids.Rotate(f.ctx, director)
mustNoErr(t, err)
})
}
// AC-8: the TOTP secret is encrypted at rest and never stored or logged in
// plaintext (A9).
func TestAC8_TOTPSecretEncryptedAndNeverPlaintext(t *testing.T) {
var logs bytes.Buffer
old := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil)))
t.Cleanup(func() { slog.SetDefault(old) })
sealer, err := auth.NewAEADSealer(bytes.Repeat([]byte{0x7f}, auth.KeySize))
mustNoErr(t, err)
f := newFixture(t, auth.WithSealer(sealer))
admin := f.admin()
id, _ := f.activeIdentity(admin)
secret := []byte("JBSWY3DPEHPK3PXP") // the shape of a base32 TOTP secret
f.expectAudit("totp.PutTOTPSecret", func() {
mustNoErr(t, f.ids.PutTOTPSecret(f.ctx, id, secret))
})
// 1) What is stored is ciphertext, not the secret.
var raw []byte
if err := f.db.QueryRow(`SELECT secret_encrypted FROM totp_secrets WHERE identity_id = ?`, id).Scan(&raw); err != nil {
t.Fatalf("read secret_encrypted: %v", err)
}
if bytes.Equal(raw, secret) || bytes.Contains(raw, secret) {
t.Error("AC-8: secret_encrypted holds the plaintext secret (A9)")
}
// 2) It still decrypts back to the secret.
got, err := f.ids.TOTPSecret(f.ctx, id)
mustNoErr(t, err)
if !bytes.Equal(got, secret) {
t.Errorf("TOTPSecret = %q, want %q", got, secret)
}
// 3) No audit row carries the secret.
rows, err := f.db.Query(
`SELECT COALESCE(action,''), COALESCE(subject,''), COALESCE(detail,''), COALESCE(correlation_id,'') FROM audit_log`,
)
mustNoErr(t, err)
defer rows.Close()
for rows.Next() {
var a, s, d, c string
mustNoErr(t, rows.Scan(&a, &s, &d, &c))
if strings.Contains(a+s+d+c, string(secret)) {
t.Error("AC-8: an audit row contains the TOTP secret (A9)")
}
}
mustNoErr(t, rows.Err())
// 4) Nothing reached the log.
if strings.Contains(logs.String(), string(secret)) {
t.Error("AC-8: the TOTP secret reached the log (A9)")
}
// Verification is audited too and never stores plaintext.
f.expectAudit("totp.MarkTOTPVerified", func() {
mustNoErr(t, f.ids.MarkTOTPVerified(f.ctx, id))
})
}
func TestTOTPWithoutSealerFailsClosed(t *testing.T) {
f := newFixture(t) // no sealer
admin := f.admin()
id, _ := f.activeIdentity(admin)
if err := f.ids.PutTOTPSecret(f.ctx, id, []byte("secret")); !errors.Is(err, auth.ErrNoSealer) {
t.Errorf("PutTOTPSecret without a sealer = %v, want ErrNoSealer (fail closed)", err)
}
}
// The Identities service satisfies routing.Resolver end to end.
func TestResolverContract(t *testing.T) {
f := newFixture(t)
admin := f.admin()
if found, enrolled := f.ids.GUID("definitely-unknown"); found || enrolled {
t.Error("unknown GUID reported found")
}
if found, _ := f.ids.GUID(""); found {
t.Error("empty GUID reported found")
}
id, guid, err := f.ids.Mint(f.ctx, admin) // pending
mustNoErr(t, err)
if found, enrolled := f.ids.GUID(guid); !found || enrolled {
t.Errorf("pending GUID: found=%v enrolled=%v, want true/false", found, enrolled)
}
mustNoErr(t, f.ids.Activate(f.ctx, id))
if found, enrolled := f.ids.GUID(guid); !found || !enrolled {
t.Errorf("active GUID: found=%v enrolled=%v, want true/true", found, enrolled)
}
}
func TestIdentityLifecycleErrors(t *testing.T) {
f := newFixture(t)
admin := f.admin()
if _, _, err := f.ids.Mint(f.ctx, 999); !errors.Is(err, auth.ErrUnknownUser) {
t.Errorf("Mint(unknown user) = %v, want ErrUnknownUser", err)
}
if _, err := f.ids.Rotate(f.ctx, 999); !errors.Is(err, auth.ErrUnknownUser) {
t.Errorf("Rotate(unknown user) = %v, want ErrUnknownUser", err)
}
id, _, err := f.ids.Mint(f.ctx, admin)
mustNoErr(t, err)
mustNoErr(t, f.ids.Activate(f.ctx, id))
if err := f.ids.Activate(f.ctx, id); !errors.Is(err, auth.ErrIdentityNotPending) {
t.Errorf("Activate twice = %v, want ErrIdentityNotPending", err)
}
// A live identity blocks a second Mint.
if _, _, err := f.ids.Mint(f.ctx, admin); !errors.Is(err, auth.ErrIdentityExists) {
t.Errorf("second Mint = %v, want ErrIdentityExists", err)
}
if _, err := f.sessions.Mint(f.ctx, id, "ip", "ua"); err != nil {
t.Fatalf("Mint session for active identity: %v", err)
}
}
func TestSessionMintRequiresActiveIdentity(t *testing.T) {
f := newFixture(t)
admin := f.admin()
pending, _, err := f.ids.Mint(f.ctx, admin) // still pending
mustNoErr(t, err)
if _, err := f.sessions.Mint(f.ctx, pending, "ip", "ua"); !errors.Is(err, auth.ErrIdentityNotActive) {
t.Errorf("Mint for pending identity = %v, want ErrIdentityNotActive", err)
}
if _, err := f.sessions.Mint(f.ctx, 999, "ip", "ua"); !errors.Is(err, auth.ErrIdentityNotFound) {
t.Errorf("Mint for unknown identity = %v, want ErrIdentityNotFound", err)
}
}
func TestSessionGCKeepsValidSessions(t *testing.T) {
f := newFixture(t, auth.WithSessionTTL(time.Hour))
admin := f.admin()
id, _ := f.activeIdentity(admin)
stale1, err := f.sessions.Mint(f.ctx, id, "ip", "ua")
mustNoErr(t, err)
stale2, err := f.sessions.Mint(f.ctx, id, "ip", "ua")
mustNoErr(t, err)
f.clock.Advance(2 * time.Hour) // both stale sessions are now expired
fresh, err := f.sessions.Mint(f.ctx, id, "ip", "ua")
mustNoErr(t, err)
n, err := f.sessions.GC(f.ctx, f.clock.Now())
mustNoErr(t, err)
if n != 2 {
t.Errorf("GC removed %d rows, want 2", n)
}
// The valid session survives — GC never takes the last valid session.
if _, err := f.sessions.Validate(f.ctx, fresh.ID); err != nil {
t.Errorf("GC removed a valid session: %v", err)
}
for _, sid := range []string{stale1.ID, stale2.ID} {
if _, err := f.sessions.Validate(f.ctx, sid); !errors.Is(err, auth.ErrSessionNotFound) {
t.Errorf("stale session %s = %v, want ErrSessionNotFound after GC", sid, err)
}
}
}
func TestEnrollmentWindow(t *testing.T) {
f := newFixture(t, auth.WithEnrollWindow(72*time.Hour))
admin := f.admin()
id, _, err := f.ids.Mint(f.ctx, admin) // pending
mustNoErr(t, err)
ok, err := f.ids.WithinEnrollmentWindow(f.ctx, id)
mustNoErr(t, err)
if !ok {
t.Error("a fresh pending identity is outside its enrollment window")
}
f.clock.Advance(73 * time.Hour)
if ok, err = f.ids.WithinEnrollmentWindow(f.ctx, id); err != nil {
t.Fatalf("WithinEnrollmentWindow: %v", err)
}
if ok {
t.Error("an identity past its window is still enrollable (A1)")
}
}
// R-4: the identity services take the ACTOR from the audit.Meta on the context
// (creator/approver) and record the AFFECTED user as the subject — never a
// hard-coded owner.
func TestR4_IdentityAuditActorFromContext(t *testing.T) {
f := newFixture(t)
admin := f.admin()
director, err := f.users.Create(f.ctx, admin, "d@example.com", "D", users.RoleDirector)
mustNoErr(t, err)
// The creator acts with an explicit actor on the context (the door would
// attach it); the affected user is the subject.
ctx := audit.WithMeta(f.ctx, audit.Meta{ActorUserID: admin, ClientIP: "203.0.113.9", CorrelationID: "corr-r4"})
id, guid, err := f.ids.Mint(ctx, director)
mustNoErr(t, err)
mustNoErr(t, f.ids.Activate(ctx, id))
mustNoErr(t, f.ids.Revoke(ctx, id))
if _, err := f.ids.Rotate(ctx, director); err != nil {
t.Fatalf("Rotate: %v", err)
}
_ = guid
rows, err := f.db.Query(`SELECT action, actor_user_id, subject, correlation_id FROM audit_log
WHERE action IN ('identity.mint','identity.activate','identity.revoke','identity.rotate')
ORDER BY id`)
if err != nil {
t.Fatalf("query audit_log: %v", err)
}
defer rows.Close()
want := map[string]bool{
"identity.mint": true,
"identity.activate": true,
"identity.revoke": true,
"identity.rotate": true,
}
seen := 0
for rows.Next() {
var action, subject string
var actor sql.NullInt64
var corr sql.NullString
if err := rows.Scan(&action, &actor, &subject, &corr); err != nil {
t.Fatalf("scan: %v", err)
}
seen++
if !actor.Valid || actor.Int64 != admin {
t.Errorf("%s actor = %+v, want the context actor %d (R-4)", action, actor, admin)
}
if subject != fmt.Sprintf("user:%d", director) {
t.Errorf("%s subject = %q, want the affected user (R-4)", action, subject)
}
if corr.String != "corr-r4" {
t.Errorf("%s correlation = %q, want corr-r4", action, corr.String)
}
delete(want, action)
}
if err := rows.Err(); err != nil {
t.Fatalf("rows: %v", err)
}
if seen != 4 || len(want) != 0 {
t.Errorf("audited %d identity events, missing %v (R-4)", seen, want)
}
}