package server_test import ( "bytes" "context" "encoding/json" "log/slog" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" pqotp "github.com/pquerna/otp/totp" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/flow" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/webauthn" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users" ) // ── the WebAuthn fake (the ceremony is browser-bound) ──────────────────────── type fakeWebAuthn struct { beginReg, finishReg int beginLogin, finishLogin int beginErr, finishErr error } func (f *fakeWebAuthn) BeginRegistration(context.Context, webauthn.User, []webauthn.Credential) (*webauthn.Begin, error) { f.beginReg++ if f.beginErr != nil { return nil, f.beginErr } return fakeBegin("reg-challenge"), nil } func (f *fakeWebAuthn) FinishRegistration(context.Context, webauthn.User, *webauthn.Begin, *http.Request) (webauthn.Credential, error) { f.finishReg++ if f.finishErr != nil { return webauthn.Credential{}, f.finishErr } return webauthn.Credential{ ID: []byte("passkey-1"), PublicKey: []byte("cose-pubkey-1"), SignCount: 1, Transports: []string{"internal"}, AAGUID: []byte{1, 2, 3, 4}, }, nil } func (f *fakeWebAuthn) BeginLogin(context.Context, webauthn.User, []webauthn.Credential) (*webauthn.Begin, error) { f.beginLogin++ if f.beginErr != nil { return nil, f.beginErr } return fakeBegin("login-challenge"), nil } func (f *fakeWebAuthn) FinishLogin(context.Context, webauthn.User, *webauthn.Begin, *http.Request, []webauthn.Credential) (webauthn.Credential, error) { f.finishLogin++ if f.finishErr != nil { return webauthn.Credential{}, f.finishErr } return webauthn.Credential{ ID: []byte("passkey-1"), PublicKey: []byte("cose-pubkey-1"), SignCount: 2, Transports: []string{"internal"}, AAGUID: []byte{1, 2, 3, 4}, }, nil } func fakeBegin(challenge string) *webauthn.Begin { opts, _ := json.Marshal(map[string]any{ "publicKey": map[string]any{"challenge": challenge}, }) sd, _ := json.Marshal(map[string]any{ "challenge": challenge, "expires": time.Now().Add(5 * time.Minute).UTC().Format(time.RFC3339), }) return &webauthn.Begin{Options: opts, SessionData: sd} } // ── harness helpers ────────────────────────────────────────────────────────── // pendingUser creates a team user under the admin and mints a pending identity. func (h *harness) pendingUser(t *testing.T, email string) (userID, identityID int64, guid string) { t.Helper() userID, err := h.users.Create(h.ctx, h.adminID, email, "Pending", users.RoleTeam) if err != nil { t.Fatalf("create user: %v", err) } identityID, guid, err = h.ids.Mint(h.ctx, userID) if err != nil { t.Fatalf("Mint: %v", err) } return userID, identityID, guid } // action issues an anonymous flow action with a fresh pre-session CSRF token. func (h *harness) action(action, guid string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder { h.t.Helper() cookie, token := h.preCSRF() if form == nil { form = url.Values{} } form.Set("csrf", token) req := httptest.NewRequest("POST", "/"+guid+"?do=auth&action="+url.QueryEscape(action), strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) for _, c := range cookies { req.AddCookie(c) } rec := httptest.NewRecorder() h.srv.ServeHTTP(rec, req) return rec } // sessionAction issues a session-gated action (logout) with the session-bound // CSRF token. func (h *harness) sessionAction(action, guid, sessionID string, extra ...*http.Cookie) *httptest.ResponseRecorder { h.t.Helper() form := url.Values{"csrf": {h.csrf.Token(sessionID)}} req := httptest.NewRequest("POST", "/"+guid+"?do=auth&action="+url.QueryEscape(action), strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(&http.Cookie{Name: httpx.SessionCookieName, Value: sessionID}) for _, c := range extra { req.AddCookie(c) } rec := httptest.NewRecorder() h.srv.ServeHTTP(rec, req) return rec } // ceremonyCookie extracts the signed WebAuthn ceremony cookie from a response. func ceremonyCookie(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie { t.Helper() for _, c := range rec.Result().Cookies() { if c.Name == flow.CeremonyCookieName && c.Value != "" { return c } } t.Fatalf("response carried no %s cookie", flow.CeremonyCookieName) return nil } // sessionCookie extracts the session cookie value from a response. func sessionCookie(t *testing.T, rec *httptest.ResponseRecorder) string { t.Helper() for _, c := range rec.Result().Cookies() { if c.Name == httpx.SessionCookieName && c.Value != "" { return c.Value } } t.Fatalf("response carried no session cookie (status %d)", rec.Code) return "" } // enrollFully runs the whole two-factor enrollment and returns the session id. func (h *harness) enrollFully(t *testing.T, identityID int64, guid string) string { t.Helper() // Factor 1 — passkey. begin := h.action("enroll.begin", guid, nil) if begin.Code != http.StatusOK { t.Fatalf("enroll.begin = %d, want 200", begin.Code) } finish := h.action("enroll.finish", guid, nil, ceremonyCookie(t, begin)) if finish.Code != http.StatusOK { t.Fatalf("enroll.finish = %d, want 200", finish.Code) } // Factor 2 — TOTP. if rec := h.action("totp.begin", guid, nil); rec.Code != http.StatusOK { t.Fatalf("totp.begin = %d, want 200", rec.Code) } secret, err := h.ids.TOTPSecret(h.ctx, identityID) if err != nil { t.Fatalf("TOTPSecret: %v", err) } code, err := pqotp.GenerateCode(string(secret), time.Now()) if err != nil { t.Fatalf("GenerateCode: %v", err) } done := h.action("totp.verify", guid, url.Values{"code": {code}}) if done.Code != http.StatusSeeOther { t.Fatalf("totp.verify = %d, want 303 (enrollment complete)", done.Code) } return sessionCookie(t, done) } // ── AC-1 ───────────────────────────────────────────────────────────────────── func TestSpec006AC1_PendingRoutesToEnrollNotLogin(t *testing.T) { h := newHarness(t) _, _, guid := h.pendingUser(t, "ac1@example.com") rec := request(h.srv, "GET", "/"+guid) if rec.Code != http.StatusOK { t.Fatalf("GET /{pending-guid} = %d, want 200 (enroll)", rec.Code) } body := rec.Body.String() if !strings.Contains(body, "Enrollment") { t.Error("a pending GUID did not render the enrollment screen (AC-1)") } if strings.Contains(body, "Sign in") { t.Error("a pending GUID rendered the login screen (AC-1)") } // The anonymous screen issues the pre-session CSRF cookie (A15). if !hasCookie(rec, httpx.PreSessionCookieName) { t.Error("the enrollment screen issued no pre-session CSRF cookie (A15)") } } // ── AC-2 ───────────────────────────────────────────────────────────────────── func TestSpec006AC2_EnrollmentRequiresBothFactors(t *testing.T) { h := newHarness(t) _, id, guid := h.pendingUser(t, "ac2@example.com") // TOTP first, no passkey yet -> refused; identity stays pending. if rec := h.action("totp.begin", guid, nil); rec.Code != http.StatusOK { t.Fatalf("totp.begin = %d, want 200", rec.Code) } secret, err := h.ids.TOTPSecret(h.ctx, id) if err != nil { t.Fatalf("TOTPSecret: %v", err) } code, err := pqotp.GenerateCode(string(secret), time.Now()) if err != nil { t.Fatalf("GenerateCode: %v", err) } rec := h.action("totp.verify", guid, url.Values{"code": {code}}) if rec.Code != http.StatusBadRequest { t.Errorf("totp.verify without a passkey = %d, want 400 (AC-2)", rec.Code) } if hasCookie(rec, httpx.SessionCookieName) { t.Error("a session was minted without both factors (AC-2)") } if idn, err := h.ids.ByGUID(h.ctx, guid); err != nil || idn.Status != auth.StatusPendingEnrollment { t.Errorf("identity after one factor = %+v, want pending_enrollment (AC-2)", idn) } // Passkey, no verified TOTP -> still pending, still no session. begin := h.action("enroll.begin", guid, nil) if begin.Code != http.StatusOK { t.Fatalf("enroll.begin = %d, want 200", begin.Code) } finish := h.action("enroll.finish", guid, nil, ceremonyCookie(t, begin)) if finish.Code != http.StatusOK { t.Fatalf("enroll.finish = %d, want 200", finish.Code) } if hasCookie(finish, httpx.SessionCookieName) { t.Error("enroll.finish minted a session before TOTP (AC-2)") } if idn, err := h.ids.ByGUID(h.ctx, guid); err != nil || idn.Status != auth.StatusPendingEnrollment { t.Errorf("identity after passkey only = %+v, want pending_enrollment (AC-2)", idn) } // Now both -> active + session. code, err = pqotp.GenerateCode(string(secret), time.Now()) if err != nil { t.Fatalf("GenerateCode: %v", err) } rec = h.action("totp.verify", guid, url.Values{"code": {code}}) if rec.Code != http.StatusSeeOther { t.Fatalf("totp.verify with both factors = %d, want 303 (AC-2)", rec.Code) } if !hasCookie(rec, httpx.SessionCookieName) { t.Error("no session minted after both factors (AC-2)") } idn, err := h.ids.ByGUID(h.ctx, guid) if err != nil { t.Fatalf("ByGUID: %v", err) } if idn.Status != auth.StatusActive { t.Errorf("identity after both factors = %s, want active (AC-2)", idn.Status) } } // ── AC-3 ───────────────────────────────────────────────────────────────────── func TestSpec006AC3_ActiveRoutesToLoginThenApp(t *testing.T) { h := newHarness(t) // the admin is active rec := request(h.srv, "GET", "/"+h.guid) if rec.Code != http.StatusOK { t.Errorf("GET /{active-guid} without a session = %d, want 200 (login screen, F-1)", rec.Code) } if !strings.Contains(rec.Body.String(), "Sign in") { t.Error("an active GUID without a session did not render the login screen (AC-3)") } rec = request(h.srv, "GET", "/"+h.guid, h.cookie()) if rec.Code != http.StatusOK { t.Fatalf("GET /{active-guid} with a session = %d, want 200 (the app)", rec.Code) } if !strings.Contains(rec.Body.String(), "MyDay") { t.Error("a valid session did not reach the app (AC-3)") } } // ── AC-4 ───────────────────────────────────────────────────────────────────── func TestSpec006AC4_WindowExpiryBlocksEnrollment(t *testing.T) { h := newHarness(t) _, id, guid := h.pendingUser(t, "ac4@example.com") // Age the identity beyond the bounded window (A1). past := store.FormatTime(time.Now().Add(-100 * time.Hour)) if _, err := h.db.Exec(`UPDATE identities SET created_at = ? WHERE identity_id = ?`, past, id); err != nil { t.Fatalf("age identity: %v", err) } if rec := request(h.srv, "GET", "/"+guid); rec.Code != http.StatusNotFound { t.Errorf("GET /{expired-guid} = %d, want 404 (A1)", rec.Code) } if rec := h.action("enroll.begin", guid, nil); rec.Code != http.StatusNotFound { t.Errorf("enroll.begin past the window = %d, want 404 (A1)", rec.Code) } if rec := h.action("totp.begin", guid, nil); rec.Code != http.StatusNotFound { t.Errorf("totp.begin past the window = %d, want 404 (A1)", rec.Code) } } // ── AC-5 ───────────────────────────────────────────────────────────────────── func TestSpec006AC5_LoginRotatesAndLogoutRevokes(t *testing.T) { h := newHarness(t) _, id, guid := h.pendingUser(t, "ac5@example.com") s1 := h.enrollFully(t, id, guid) if _, err := h.sessions.Validate(h.ctx, s1); err != nil { t.Fatalf("the enrollment session is invalid: %v", err) } // A passkey login while S1 is live rotates it (fixation defence). begin := h.action("login.begin", guid, nil, &http.Cookie{Name: httpx.SessionCookieName, Value: s1}) if begin.Code != http.StatusOK { t.Fatalf("login.begin = %d, want 200", begin.Code) } finish := h.action("login.finish", guid, nil, &http.Cookie{Name: httpx.SessionCookieName, Value: s1}, ceremonyCookie(t, begin)) if finish.Code != http.StatusSeeOther { t.Fatalf("login.finish = %d, want 303", finish.Code) } s2 := sessionCookie(t, finish) if s2 == s1 { t.Error("login did not rotate the session id (AC-5)") } if _, err := h.sessions.Validate(h.ctx, s1); err == nil { t.Error("the pre-login session survived rotation (AC-5)") } if _, err := h.sessions.Validate(h.ctx, s2); err != nil { t.Errorf("the rotated session is invalid: %v", err) } // Logout revokes S2. lo := h.sessionAction("logout", guid, s2) if lo.Code != http.StatusSeeOther { t.Fatalf("logout = %d, want 303", lo.Code) } if _, err := h.sessions.Validate(h.ctx, s2); err == nil { t.Error("logout did not revoke the session (AC-5)") } } // ── AC-6 ───────────────────────────────────────────────────────────────────── func TestSpec006AC6_TOTPSecretEncryptedAndNeverLogged(t *testing.T) { var logs bytes.Buffer h := newHarness(t, func(d *server.Deps) { d.Logger = slog.New(slog.NewTextHandler(&logs, nil)) }) _, id, guid := h.pendingUser(t, "ac6@example.com") rec := h.action("totp.begin", guid, nil) if rec.Code != http.StatusOK { t.Fatalf("totp.begin = %d, want 200", rec.Code) } secret, err := h.ids.TOTPSecret(h.ctx, id) if err != nil { t.Fatalf("TOTPSecret: %v", err) } if len(secret) == 0 { t.Fatal("the issued secret is empty") } // 1) The stored blob is ciphertext, not the secret (A9). var raw []byte if err := h.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.Contains(raw, secret) { t.Error("AC-6: secret_encrypted holds the plaintext secret (A9)") } // 2) The otpauth URL is shown to the user (the one display) — but the // secret never reached the log (A9). The enroll ceremony answers the // WebAuthn client (`webauthn.js`) with JSON, not a page (SPEC-006 §4): the // setup wizard is the server-rendered exception (SPEC-005 §4). if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { t.Errorf("totp.begin Content-Type = %q, want application/json (webauthn.js's contract)", ct) } if !strings.Contains(rec.Body.String(), `"otpauth_url"`) { t.Error("totp.begin did not return the otpauth_url JSON field (webauthn.js's contract)") } if !strings.Contains(rec.Body.String(), "otpauth://") { t.Error("totp.begin did not return an otpauth URL") } if strings.Contains(logs.String(), string(secret)) { t.Error("AC-6: the TOTP secret reached the log (A9)") } } // ── AC-7 ───────────────────────────────────────────────────────────────────── func TestSpec006AC7_RateLimitAndGenericErrors(t *testing.T) { // Rate limit (A3): the enroll/login routes return 429 past the limit. h := newHarness(t, func(d *server.Deps) { d.RateLimit = 1 d.RateWindow = time.Minute }) _, _, guid := h.pendingUser(t, "ac7@example.com") if rec := request(h.srv, "GET", "/"+guid); rec.Code != http.StatusOK { t.Fatalf("first request = %d, want 200", rec.Code) } rec := request(h.srv, "GET", "/"+guid) if rec.Code != http.StatusTooManyRequests { t.Fatalf("second request = %d, want 429 (A3/AC-7)", rec.Code) } if rec.Header().Get("Retry-After") == "" { t.Error("429 is missing a Retry-After header") } // Generic error text: failures never reveal which factor or why. h2 := newHarness(t) _, id2, guid2 := h2.pendingUser(t, "ac7b@example.com") h2.enrollFully(t, id2, guid2) rec = h2.action("totp.verify", guid2, url.Values{"code": {"000000"}}) if rec.Code != http.StatusUnauthorized { t.Fatalf("bad TOTP code = %d, want 401", rec.Code) } if got := strings.TrimSpace(rec.Body.String()); got != "unauthorized" { t.Errorf("failed login body = %q, want a generic %q", got, "unauthorized") } // A missing/forged pre-session token is rejected generically. req := httptest.NewRequest("POST", "/"+guid2+"?do=auth&action=enroll.begin", nil) req.AddCookie(&http.Cookie{Name: httpx.PreSessionCookieName, Value: "forged"}) rec2 := httptest.NewRecorder() h2.srv.ServeHTTP(rec2, req) if rec2.Code != http.StatusForbidden { t.Errorf("forged pre-session token = %d, want 403 (A15)", rec2.Code) } if got := strings.TrimSpace(rec2.Body.String()); got != "forbidden" { t.Errorf("CSRF failure body = %q, want a generic %q", got, "forbidden") } } // TestF2_NoStoreOnEnrollAndTOTPResponses: the enrollment screen and the TOTP // issue response (which embeds the otpauth URL — the secret) are never cached. func TestF2_NoStoreOnEnrollAndTOTPResponses(t *testing.T) { h := newHarness(t) _, _, guid := h.pendingUser(t, "f2@example.com") rec := request(h.srv, "GET", "/"+guid) if rec.Code != http.StatusOK { t.Fatalf("enrollment screen = %d, want 200", rec.Code) } if got := rec.Header().Get("Cache-Control"); got != "no-store" { t.Errorf("enrollment screen Cache-Control = %q, want no-store (F-2)", got) } rec = h.action("totp.begin", guid, nil) if rec.Code != http.StatusOK { t.Fatalf("totp.begin = %d, want 200", rec.Code) } if got := rec.Header().Get("Cache-Control"); got != "no-store" { t.Errorf("totp.begin Cache-Control = %q, want no-store (F-2)", got) } } // hasCookie reports whether the response set a non-empty cookie of that name. func hasCookie(rec *httptest.ResponseRecorder, name string) bool { for _, c := range rec.Result().Cookies() { if c.Name == name && c.Value != "" { return true } } return false }