From c9cc745bac8a5fa2fc347aae39c6fa183d3d9c06 Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Tue, 1 Sep 2026 03:03:17 +0000 Subject: [PATCH 01/25] feat: add PurposeMagicLink, reusing the existing VerificationStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No new table — magic-link tokens are single-use, expiring, hashed tokens tied to a user, exactly what VerificationStore (already used for email-change confirmation) already models. PurposeMagicLink is a separate value from PurposeEmailVerify even though both are 'click a link in your email': the Purpose check on read is what stops a leaked/guessed email-verification link from ever being replayed as a login link, or vice versa. Also adds a magic_link_requested audit event type. --- store/interfaces.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/store/interfaces.go b/store/interfaces.go index a70ce70..f1f5d40 100644 --- a/store/interfaces.go +++ b/store/interfaces.go @@ -126,6 +126,7 @@ const ( EventWebAuthnRegistered AuditEventType = "webauthn_registered" EventWebAuthnRemoved AuditEventType = "webauthn_removed" EventWebAuthnChallengeFailed AuditEventType = "webauthn_challenge_failed" + EventMagicLinkRequested AuditEventType = "magic_link_requested" ) // AuditEvent is a single security-relevant, queryable record. @@ -164,6 +165,12 @@ type VerificationPurpose string const ( PurposeEmailVerify VerificationPurpose = "email_verify" PurposeEmailChange VerificationPurpose = "email_change" + // PurposeMagicLink marks a token as a passwordless login link — a + // separate purpose from PurposeEmailVerify even though both are + // "click a link in your email": GetByTokenHash's Purpose check is + // what stops a leaked/guessed email-verification link from ever + // being replayed as a login link, or vice versa. + PurposeMagicLink VerificationPurpose = "magic_link" ) // VerificationToken represents a single-use, expiring token sent to an From a4b54f8fea69798c81f2b186c2e9745a109c196d Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Tue, 1 Sep 2026 03:03:23 +0000 Subject: [PATCH 02/25] feat: add MagicLinkSender interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deliberately a separate interface from EmailSender rather than a new method added to it — EmailSender already shipped in an earlier release, and adding a required method to an existing interface would break every consuming app's existing implementation at compile time. The two are also genuinely different concerns for the app to word differently: 'confirm your new email' reads nothing like 'click to log in,' and EmailSender.SendVerification has no way to signal which one it's sending. --- notify/magic_link_sender.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 notify/magic_link_sender.go diff --git a/notify/magic_link_sender.go b/notify/magic_link_sender.go new file mode 100644 index 0000000..8e10e0c --- /dev/null +++ b/notify/magic_link_sender.go @@ -0,0 +1,19 @@ +package notify + +import "context" + +// MagicLinkSender delivers passwordless login links. Deliberately a +// separate interface from EmailSender rather than a new method added +// to it — EmailSender already shipped in an earlier release, and +// adding a required method to an existing interface would break every +// consuming app's existing implementation at compile time. The two +// are also genuinely different concerns for the app to word +// differently: "confirm your new email" reads nothing like "click to +// log in," and EmailSender.SendVerification has no way to signal +// which one it's sending. +type MagicLinkSender interface { + // SendMagicLink delivers rawToken to `to`. As with + // EmailSender.SendVerification, building the actual clickable URL + // is the caller's job — the engine doesn't know your routing. + SendMagicLink(ctx context.Context, to string, rawToken string) error +} From 09263878975e3efc1226ea57c115688847e802b5 Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Tue, 1 Sep 2026 03:03:29 +0000 Subject: [PATCH 03/25] refactor: extract completePrimaryAuth out of Login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls the 'check confirmed second-factor methods, then either pause with *ErrSecondFactorRequired or finish the login' logic out of Login into a standalone completePrimaryAuth, shared by any primary authentication path — magic-link login (landing in the next few commits) reuses it verbatim rather than reimplementing the same check slightly differently, which is exactly the kind of drift that could let a new login method accidentally bypass the second-factor gate. Pure extraction — Login's own behavior is unchanged. --- auth/login.go | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/auth/login.go b/auth/login.go index de7fc6d..4063950 100644 --- a/auth/login.go +++ b/auth/login.go @@ -107,9 +107,38 @@ func Login( log.Error("login: reset failed-attempts error", map[string]string{"error": err.Error(), "user_id": user.ID}) } - // Password verified. Collect any confirmed second-factor methods - // this account has enrolled — if there are any, pause here - // instead of issuing tokens directly. + // Password verified. Route through the same second-factor gate + // every primary authentication method uses (magic-link login goes + // through this too) — a correct password only ever proves the + // primary factor, never bypasses a confirmed second one. + return completePrimaryAuth(ctx, sessions, totpStore, webauthnStore, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, user, callerIP, userAgent) +} + +// completePrimaryAuth is the shared tail of every primary +// authentication path (password login, magic-link login, and any +// future one) once the caller has independently established "this +// really is the account owner." It collects any confirmed +// second-factor methods the account has enrolled — a confirmed TOTP +// secret, one or more registered passkeys, or both — and either +// pauses with *ErrSecondFactorRequired or finishes the login +// directly. Centralizing this here means a new primary auth method +// can never accidentally skip the second-factor gate by reimplementing +// this check slightly differently. +func completePrimaryAuth( + ctx context.Context, + sessions store.SessionStore, + totpStore store.TOTPStore, + webauthnStore store.WebAuthnCredentialStore, + ids security.IDGenerator, + refreshGen token.TokenGenerator, + jwtIssuer *token.JWTIssuer, + pendingIssuer *token.MFAPendingIssuer, + audit store.AuditStore, + log logger.Logger, + user store.User, + callerIP string, + userAgent string, +) (Tokens, error) { var methods []string if totpStore != nil { secretRec, err := totpStore.GetByUserID(ctx, user.ID) @@ -128,7 +157,7 @@ func Login( if issueErr != nil { return Tokens{}, issueErr } - log.Info("login: password verified, awaiting second factor", map[string]string{"user_id": user.ID}) + log.Info("login: primary factor verified, awaiting second factor", map[string]string{"user_id": user.ID}) return Tokens{}, &ErrSecondFactorRequired{PendingToken: pendingToken, Methods: methods} } From 8ce6b99c4fef49f1afd9360f357876a530a2f00c Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Tue, 1 Sep 2026 03:03:36 +0000 Subject: [PATCH 04/25] feat: add RequestMagicLink and CompleteMagicLink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RequestMagicLink logs in an existing account only — it never creates one. Returns nil for a nonexistent email exactly as it would for a real one, and never calls the sender in that case, to avoid leaking which emails are registered; a genuine delivery failure for an existing account still propagates, since that's an operational concern distinct from enumeration. CompleteMagicLink marks the token used immediately after validation, before any second-factor check or session creation, so a link can never be replayed even if something later in the call fails. It routes through the same completePrimaryAuth gate password login uses — an account with TOTP/a passkey enrolled pauses here exactly as it would after a correct password. magicLinkTTL is fixed at 15 minutes, not configurable — same reasoning as mfaPendingTTL: a passwordless login link is a bearer credential for the account it's mailed to, and a tuning knob here invites widening it well past what 'click the link you just got' actually needs. --- auth/magiclink.go | 156 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 auth/magiclink.go diff --git a/auth/magiclink.go b/auth/magiclink.go new file mode 100644 index 0000000..b7052ba --- /dev/null +++ b/auth/magiclink.go @@ -0,0 +1,156 @@ +package auth + +import ( + "context" + "time" + + "github.com/crydensync/cryden/v2/logger" + "github.com/crydensync/cryden/v2/notify" + "github.com/crydensync/cryden/v2/security" + "github.com/crydensync/cryden/v2/store" + "github.com/crydensync/cryden/v2/token" +) + +// magicLinkTTL is how long a login link stays valid — fixed, not +// configurable, same reasoning as mfaPendingTTL: a passwordless login +// link is a bearer credential for the account it's mailed to, and +// making its lifetime a tuning knob invites a deployment to widen it +// well past what "click the link you just got" actually needs. 15 +// minutes is generous enough to survive someone switching to their +// email app without leaving a long-lived credential sitting in an +// inbox. +const magicLinkTTL = 15 * time.Minute + +// RequestMagicLink sends a passwordless login link to email, for an +// EXISTING account only — this does not create accounts. To avoid +// leaking which emails are registered, it returns nil regardless of +// whether the account exists; the email is only actually sent when it +// does. A genuine delivery failure (the sender's own error) still +// propagates for an existing account, since that's an operational +// concern distinct from enumeration — silently swallowing real send +// failures would hide delivery problems from monitoring for no real +// security benefit. +func RequestMagicLink( + ctx context.Context, + users store.UserStore, + verifications store.VerificationStore, + sender notify.MagicLinkSender, + tokenGen token.TokenGenerator, + ids security.IDGenerator, + limiter security.RateLimiter, + audit store.AuditStore, + log logger.Logger, + email string, + callerIP string, +) error { + allowed, err := limiter.Allow(ctx, "magic-link:"+callerIP+":"+email) + if err != nil { + log.Error("request magic link: rate limiter error", map[string]string{"error": err.Error()}) + return err + } + if !allowed { + log.Warn("request magic link: rate limited", map[string]string{"ip": callerIP}) + return ErrRateLimited + } + + user, err := users.GetByEmail(ctx, email) + if err != nil { + // No such account — return nil rather than an error, same + // enumeration-avoidance reasoning as Login's nonexistent-user + // path. Unlike Login, there's no password hash to pay the + // cost of here — the response never contains anything for an + // attacker to time against beyond "did an email get sent," + // which they can't observe directly anyway. + log.Info("magic link requested for unknown email", map[string]string{"ip": callerIP}) + return nil + } + + rawToken, err := tokenGen.New() + if err != nil { + return err + } + id, err := ids.New() + if err != nil { + return err + } + + vt := store.VerificationToken{ + ID: id, + UserID: user.ID, + Purpose: store.PurposeMagicLink, + TokenHash: token.HashToken(rawToken), + ExpiresAt: time.Now().Add(magicLinkTTL), + } + if err := verifications.Create(ctx, vt); err != nil { + return err + } + + if err := sender.SendMagicLink(ctx, email, rawToken); err != nil { + return err + } + + if err := audit.Record(ctx, store.AuditEvent{ + Type: store.EventMagicLinkRequested, + UserID: user.ID, + IP: callerIP, + }); err != nil { + log.Error("request magic link: audit record failed", map[string]string{"error": err.Error(), "user_id": user.ID}) + } + + log.Info("magic link requested", map[string]string{"user_id": user.ID}) + return nil +} + +// CompleteMagicLink logs in using the raw token from a link sent by +// RequestMagicLink. The token is single-use — MarkUsed is called as +// soon as it passes validation, before any second-factor check or +// session creation, so a link can never be replayed even if something +// later in this call fails. +// +// Clicking a valid link proves email ownership, the primary factor — +// it does not bypass a confirmed second factor. This routes through +// the exact same completePrimaryAuth gate password login uses, so an +// account with TOTP/a passkey enrolled pauses here exactly as it +// would after a correct password. +func CompleteMagicLink( + ctx context.Context, + users store.UserStore, + sessions store.SessionStore, + verifications store.VerificationStore, + totpStore store.TOTPStore, + webauthnStore store.WebAuthnCredentialStore, + ids security.IDGenerator, + refreshGen token.TokenGenerator, + jwtIssuer *token.JWTIssuer, + pendingIssuer *token.MFAPendingIssuer, + audit store.AuditStore, + log logger.Logger, + rawToken string, + callerIP string, + userAgent string, +) (Tokens, error) { + vt, err := verifications.GetByTokenHash(ctx, token.HashToken(rawToken)) + if err != nil { + return Tokens{}, ErrVerificationTokenInvalid + } + if vt.Purpose != store.PurposeMagicLink { + return Tokens{}, ErrVerificationTokenInvalid + } + if vt.UsedAt != nil { + return Tokens{}, ErrVerificationTokenInvalid + } + if time.Now().After(vt.ExpiresAt) { + return Tokens{}, ErrVerificationTokenExpired + } + + if err := verifications.MarkUsed(ctx, vt.ID); err != nil { + log.Error("complete magic link: mark-used failed", map[string]string{"error": err.Error(), "user_id": vt.UserID}) + } + + user, err := users.GetByID(ctx, vt.UserID) + if err != nil { + return Tokens{}, err + } + + return completePrimaryAuth(ctx, sessions, totpStore, webauthnStore, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, user, callerIP, userAgent) +} From a024ddca3cf51d6b87f9e6962d226acbad53e7ef Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Tue, 1 Sep 2026 03:03:41 +0000 Subject: [PATCH 05/25] test: add RequestMagicLink/CompleteMagicLink tests Covers: sending only for existing accounts and never revealing which emails aren't registered, single-use enforcement, expiry, a wrong-purpose token (e.g. email-change) correctly rejected as a login token, and an account with TOTP enrolled correctly pausing for a second factor on completion instead of logging straight in. --- auth/magiclink_test.go | 209 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 auth/magiclink_test.go diff --git a/auth/magiclink_test.go b/auth/magiclink_test.go new file mode 100644 index 0000000..3783af2 --- /dev/null +++ b/auth/magiclink_test.go @@ -0,0 +1,209 @@ +package auth + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/crydensync/cryden/v2/security" + "github.com/crydensync/cryden/v2/store" + "github.com/crydensync/cryden/v2/store/memory" + "github.com/crydensync/cryden/v2/token" +) + +type captureMagicLinkSender struct { + to string + rawToken string + calls int +} + +func (c *captureMagicLinkSender) SendMagicLink(ctx context.Context, to string, rawToken string) error { + c.to = to + c.rawToken = rawToken + c.calls++ + return nil +} + +func newMagicLinkTestDeps(t *testing.T) (*memory.UserStore, *memory.VerificationStore, *memory.AuditStore, security.IDGenerator, token.TokenGenerator, *captureMagicLinkSender, security.RateLimiter) { + t.Helper() + users := memory.NewUserStore() + verifications := memory.NewVerificationStore() + audit := memory.NewAuditStore() + ids := security.NewUUIDv7Generator() + tokenGen, _ := token.NewCryptoRandTokenGenerator(32) + sender := &captureMagicLinkSender{} + limiter := security.NewInMemoryRateLimiter(1000, time.Minute) + return users, verifications, audit, ids, tokenGen, sender, limiter +} + +func TestRequestMagicLink_SendsForExistingAccount(t *testing.T) { + users, verifications, audit, ids, tokenGen, sender, limiter := newMagicLinkTestDeps(t) + log := testLogger{} + ctx := context.Background() + + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", "hash")) + + err := RequestMagicLink(ctx, users, verifications, sender, tokenGen, ids, limiter, audit, log, "raymondproguy@dev.com", "1.2.3.4") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sender.calls != 1 { + t.Fatalf("expected exactly 1 send, got %d", sender.calls) + } + if sender.to != "raymondproguy@dev.com" { + t.Errorf("expected send to raymondproguy@dev.com, got %q", sender.to) + } + if sender.rawToken == "" { + t.Error("expected a non-empty token") + } +} + +func TestRequestMagicLink_NonexistentEmailReturnsNilWithoutSending(t *testing.T) { + // Enumeration-avoidance: must return the same nil as a real + // account, and must never call the sender for an address with no + // account behind it. + users, verifications, audit, ids, tokenGen, sender, limiter := newMagicLinkTestDeps(t) + log := testLogger{} + ctx := context.Background() + + err := RequestMagicLink(ctx, users, verifications, sender, tokenGen, ids, limiter, audit, log, "nobody@example.com", "1.2.3.4") + if err != nil { + t.Errorf("expected nil error for a nonexistent email, got %v", err) + } + if sender.calls != 0 { + t.Errorf("expected the sender to never be called for a nonexistent email, got %d calls", sender.calls) + } +} + +func TestCompleteMagicLink_ValidTokenIssuesTokens(t *testing.T) { + users, verifications, audit, ids, tokenGen, sender, limiter := newMagicLinkTestDeps(t) + log := testLogger{} + ctx := context.Background() + sessions := memory.NewSessionStore() + refreshGen, _ := token.NewCryptoRandTokenGenerator(32) + jwtIssuer, _ := token.NewJWTIssuer("test-secret", time.Minute) + pendingIssuer, _ := token.NewMFAPendingIssuer("test-secret") + + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", "hash")) + if err := RequestMagicLink(ctx, users, verifications, sender, tokenGen, ids, limiter, audit, log, "raymondproguy@dev.com", "1.2.3.4"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + tokens, err := CompleteMagicLink(ctx, users, sessions, verifications, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, sender.rawToken, "1.2.3.4", "test-agent") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tokens.AccessToken == "" || tokens.RefreshToken == "" { + t.Error("expected both tokens to be populated") + } +} + +func TestCompleteMagicLink_TokenIsSingleUse(t *testing.T) { + users, verifications, audit, ids, tokenGen, sender, limiter := newMagicLinkTestDeps(t) + log := testLogger{} + ctx := context.Background() + sessions := memory.NewSessionStore() + refreshGen, _ := token.NewCryptoRandTokenGenerator(32) + jwtIssuer, _ := token.NewJWTIssuer("test-secret", time.Minute) + pendingIssuer, _ := token.NewMFAPendingIssuer("test-secret") + + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", "hash")) + RequestMagicLink(ctx, users, verifications, sender, tokenGen, ids, limiter, audit, log, "raymondproguy@dev.com", "1.2.3.4") + + if _, err := CompleteMagicLink(ctx, users, sessions, verifications, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, sender.rawToken, "1.2.3.4", "test-agent"); err != nil { + t.Fatalf("unexpected error on first use: %v", err) + } + + _, err := CompleteMagicLink(ctx, users, sessions, verifications, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, sender.rawToken, "1.2.3.4", "test-agent") + if err != ErrVerificationTokenInvalid { + t.Errorf("expected ErrVerificationTokenInvalid on reuse, got %v", err) + } +} + +func TestCompleteMagicLink_ExpiredTokenRejected(t *testing.T) { + users, verifications, audit, ids, _, _, _ := newMagicLinkTestDeps(t) + log := testLogger{} + ctx := context.Background() + sessions := memory.NewSessionStore() + refreshGen, _ := token.NewCryptoRandTokenGenerator(32) + jwtIssuer, _ := token.NewJWTIssuer("test-secret", time.Minute) + pendingIssuer, _ := token.NewMFAPendingIssuer("test-secret") + tokenGen, _ := token.NewCryptoRandTokenGenerator(32) + + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", "hash")) + + rawToken, _ := tokenGen.New() + id, _ := ids.New() + verifications.Create(ctx, store.VerificationToken{ + ID: id, + UserID: "user-1", + Purpose: store.PurposeMagicLink, + TokenHash: token.HashToken(rawToken), + ExpiresAt: time.Now().Add(-1 * time.Minute), // already expired + }) + + _, err := CompleteMagicLink(ctx, users, sessions, verifications, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, rawToken, "1.2.3.4", "test-agent") + if err != ErrVerificationTokenExpired { + t.Errorf("expected ErrVerificationTokenExpired, got %v", err) + } +} + +func TestCompleteMagicLink_WrongPurposeTokenRejected(t *testing.T) { + // A token minted for a different purpose (e.g. email change) must + // never be usable to log in, even if someone got hold of its raw + // value — the Purpose check is what enforces that separation. + users, verifications, audit, ids, _, _, _ := newMagicLinkTestDeps(t) + log := testLogger{} + ctx := context.Background() + sessions := memory.NewSessionStore() + refreshGen, _ := token.NewCryptoRandTokenGenerator(32) + jwtIssuer, _ := token.NewJWTIssuer("test-secret", time.Minute) + pendingIssuer, _ := token.NewMFAPendingIssuer("test-secret") + tokenGen, _ := token.NewCryptoRandTokenGenerator(32) + + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", "hash")) + + rawToken, _ := tokenGen.New() + id, _ := ids.New() + verifications.Create(ctx, store.VerificationToken{ + ID: id, + UserID: "user-1", + Purpose: store.PurposeEmailChange, + TokenHash: token.HashToken(rawToken), + ExpiresAt: time.Now().Add(1 * time.Hour), + }) + + _, err := CompleteMagicLink(ctx, users, sessions, verifications, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, rawToken, "1.2.3.4", "test-agent") + if err != ErrVerificationTokenInvalid { + t.Errorf("expected ErrVerificationTokenInvalid for a wrong-purpose token, got %v", err) + } +} + +func TestCompleteMagicLink_AccountWithTOTPPausesForSecondFactor(t *testing.T) { + users, verifications, audit, ids, tokenGen, sender, limiter := newMagicLinkTestDeps(t) + log := testLogger{} + ctx := context.Background() + sessions := memory.NewSessionStore() + refreshGen, _ := token.NewCryptoRandTokenGenerator(32) + jwtIssuer, _ := token.NewJWTIssuer("test-secret", time.Minute) + pendingIssuer, _ := token.NewMFAPendingIssuer("test-secret") + totpStore := memory.NewTOTPStore() + totpGen := security.NewPquernaTOTPGenerator() + enc, _ := security.NewAESGCMEncryptor("test-encryption-key") + + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", "hash")) + enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") + + RequestMagicLink(ctx, users, verifications, sender, tokenGen, ids, limiter, audit, log, "raymondproguy@dev.com", "1.2.3.4") + + _, err := CompleteMagicLink(ctx, users, sessions, verifications, totpStore, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, sender.rawToken, "1.2.3.4", "test-agent") + + var secondFactor *ErrSecondFactorRequired + if !errors.As(err, &secondFactor) { + t.Fatalf("expected *ErrSecondFactorRequired, got %v", err) + } + if len(secondFactor.Methods) != 1 || secondFactor.Methods[0] != "totp" { + t.Errorf("expected Methods == [\"totp\"], got %v", secondFactor.Methods) + } +} From 11c021877e465553afe650751aea9432f96f88c4 Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Tue, 1 Sep 2026 03:03:46 +0000 Subject: [PATCH 06/25] feat: wire magic-link login into Config, Engine, and the public facade - Config.MagicLinkSender (optional, notify.MagicLinkSender); requires Config.Verifications to also be set (validated in New). - New facade functions: RequestMagicLink, CompleteMagicLink, each returning cryden.ErrMagicLinkNotConfigured if called without Config.MagicLinkSender set. --- config.go | 8 ++++++++ cryden.go | 30 ++++++++++++++++++++++++++++++ engine.go | 18 ++++++++++-------- errors.go | 5 +++++ 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/config.go b/config.go index ab3192a..07f0167 100644 --- a/config.go +++ b/config.go @@ -26,6 +26,11 @@ type Config struct { // rather than a nil-pointer panic. Verifications store.VerificationStore EmailSender notify.EmailSender + // MagicLinkSender is optional — only required if RequestMagicLink + // is used. Requires Verifications to also be set (magic-link + // tokens reuse the same VerificationStore email-change/verify + // tokens use, distinguished by store.PurposeMagicLink). + MagicLinkSender notify.MagicLinkSender // OAuth is optional — only required if LoginWithOAuth is used. // Left unset, LoginWithOAuth returns ErrOAuthNotConfigured. OAuth store.OAuthStore @@ -109,6 +114,9 @@ func (c *Config) validate() error { return ErrMissingWebAuthnConfig } } + if c.MagicLinkSender != nil && c.Verifications == nil { + return ErrMissingVerificationStore + } return nil } diff --git a/cryden.go b/cryden.go index 66a7f6e..ba29e8e 100644 --- a/cryden.go +++ b/cryden.go @@ -328,3 +328,33 @@ func CompleteLoginWithWebAuthn(ctx context.Context, e *Engine, pendingToken, cer } return auth.CompleteLoginWithWebAuthn(ctx, e.users, e.sessions, e.webauthn, e.webauthnProvider, e.encryptor, e.ids, e.refreshGen, e.jwtIssuer, e.pendingIssuer, e.audit, e.log, pendingToken, ceremonyToken, clientResponseJSON, callerIP, userAgent) } + +// ErrMagicLinkNotConfigured is returned by RequestMagicLink and +// CompleteMagicLink if the Engine was built without +// Config.MagicLinkSender set. +var ErrMagicLinkNotConfigured = errors.New("cryden: magic-link login requires Config.MagicLinkSender (and Config.Verifications) to be set") + +// RequestMagicLink sends a passwordless login link to email, for an +// existing account only — this does not create accounts. Always +// returns nil for a nonexistent email (an email is only actually sent +// when the account exists) to avoid leaking which emails are +// registered; a genuine delivery failure for an existing account +// still propagates. +func RequestMagicLink(ctx context.Context, e *Engine, email, callerIP string) error { + if e.magicLinkSender == nil { + return ErrMagicLinkNotConfigured + } + return auth.RequestMagicLink(ctx, e.users, e.verifications, e.magicLinkSender, e.refreshGen, e.ids, e.rateLimiter, e.audit, e.log, email, callerIP) +} + +// CompleteMagicLink logs in using the raw token from a link sent by +// RequestMagicLink. Like Login, it routes through the same +// second-factor gate — an account with TOTP/a passkey enrolled pauses +// with *auth.ErrSecondFactorRequired here exactly as it would after a +// correct password, retrievable via errors.As. +func CompleteMagicLink(ctx context.Context, e *Engine, rawToken, callerIP, userAgent string) (Tokens, error) { + if e.magicLinkSender == nil { + return Tokens{}, ErrMagicLinkNotConfigured + } + return auth.CompleteMagicLink(ctx, e.users, e.sessions, e.verifications, e.totp, e.webauthn, e.ids, e.refreshGen, e.jwtIssuer, e.pendingIssuer, e.audit, e.log, rawToken, callerIP, userAgent) +} diff --git a/engine.go b/engine.go index 0e410d8..4eb10ad 100644 --- a/engine.go +++ b/engine.go @@ -14,14 +14,15 @@ import ( // functions (SignUp, Login, etc. in cryden.go). Consumers never // construct this directly — always via New(cfg). type Engine struct { - users store.UserStore - sessions store.SessionStore - audit store.AuditStore - verifications store.VerificationStore - emailSender notify.EmailSender - oauth store.OAuthStore - totp store.TOTPStore - webauthn store.WebAuthnCredentialStore + users store.UserStore + sessions store.SessionStore + audit store.AuditStore + verifications store.VerificationStore + emailSender notify.EmailSender + oauth store.OAuthStore + totp store.TOTPStore + webauthn store.WebAuthnCredentialStore + magicLinkSender notify.MagicLinkSender hasher security.Hasher ids security.IDGenerator @@ -103,6 +104,7 @@ func New(cfg Config) (*Engine, error) { oauth: cfg.OAuth, totp: cfg.TOTP, webauthn: cfg.WebAuthn, + magicLinkSender: cfg.MagicLinkSender, hasher: hasher, ids: security.NewUUIDv7Generator(), rateLimiter: security.NewInMemoryRateLimiter(cfg.RateLimitAttempts, cfg.RateLimitWindow), diff --git a/errors.go b/errors.go index 495ecfe..485ab3e 100644 --- a/errors.go +++ b/errors.go @@ -18,4 +18,9 @@ var ( // has a safe default (RPID especially: guessing wrong binds every // registered passkey to the wrong domain). ErrMissingWebAuthnConfig = errors.New("cryden: WebAuthnRPID, WebAuthnRPDisplayName, and WebAuthnRPOrigins are all required when Config.WebAuthn is set") + // ErrMissingVerificationStore is returned by New if + // Config.MagicLinkSender is set but Config.Verifications isn't — + // magic-link tokens are stored there, alongside email-change + // tokens, distinguished by purpose. + ErrMissingVerificationStore = errors.New("cryden: Config.Verifications is required when Config.MagicLinkSender is set") ) From a36c6bce27e0fcb8587ab1af84f90ad24f84dd42 Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Tue, 1 Sep 2026 03:03:54 +0000 Subject: [PATCH 07/25] docs: document magic-link login in README Also fixes a stale 'not in v2' line that still listed WebAuthn and was about to incorrectly list magic links too, now that both are built; notes passwordless-primary passkey login as the planned fast-follow this and WebAuthn's shared plumbing sets up. --- README.md | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 162f333..619777a 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,31 @@ if errors.As(err, &secondFactor) { `ListPasskeys(ctx, engine, userID)` lists registered passkeys (nickname, creation time, last used). `DeletePasskey(ctx, engine, userID, credentialID, currentPassword)` removes one — requires the current password, same reasoning as `DisableTOTP`. Calling any passkey function without `Config.WebAuthn` set returns `cryden.ErrWebAuthnNotConfigured`. +## Magic-link (passwordless) login + +Requires one additional `Config` field: + +```go +engine, err := cryden.New(cryden.Config{ + // ...required fields, and Verifications (shared with email-change tokens)... + MagicLinkSender: yourMagicLinkSender, // implements notify.MagicLinkSender +}) +``` + +`MagicLinkSender` is a separate interface from `EmailSender` — not a new method added to it, since `EmailSender` already shipped and adding a required method would break every existing implementation. `Config.Verifications` must also be set; magic-link tokens reuse the same store email-change tokens use, distinguished by purpose internally. + +This logs in an **existing account only** — it doesn't create one: + +```go +err := cryden.RequestMagicLink(ctx, engine, email, callerIP) +// always nil for a nonexistent email too (avoids leaking which emails are registered); +// a real delivery failure for an existing account still returns as an error + +tokens, err := cryden.CompleteMagicLink(ctx, engine, rawTokenFromTheLink, callerIP, userAgent) +``` + +The link is valid for 15 minutes and single-use — clicking it a second time fails the same way an expired one does. Like `Login`, `CompleteMagicLink` routes through the same second-factor gate: an account with TOTP/a passkey enrolled returns `*auth.ErrSecondFactorRequired` here exactly as it would after a correct password — clicking the link proves email ownership, the primary factor, not a bypass of a confirmed second one. Calling either function without `Config.MagicLinkSender` set returns `cryden.ErrMagicLinkNotConfigured`. + ## AI-assisted admin queries (library support only) The `ai` subpackage provides the safety machinery for natural-language admin tooling — an allowlisted `QueryIntent` type, `validateIntent`, and `ExecuteQuery` — plus `store/postgres.SafeQueryStore`, a read-only query executor. This is a foundation for tools like `csax`'s CLI to build on, not a feature you call directly in application code. An LLM's output is treated as untrusted data to validate against a strict allowlist, never as SQL to execute — and the actual DB connection passed to `SafeQueryStore` must be opened with a read-only Postgres role, since that's the real safety boundary, not just the allowlist check. `ai.LLMProvider` ships zero implementations; bring your own (OpenAI, Anthropic, OpenRouter, a local model). @@ -233,6 +258,7 @@ The `ai` subpackage provides the safety machinery for natural-language admin too - Signup, login, logout (single device + all devices) - OAuth login/signup (Google, GitHub, or any provider) with explicit, non-auto-linking account collision handling — see [OAuth](#oauth-google-github-or-any-provider) - Two-factor authentication: TOTP and passkeys (WebAuthn), unified under one pause state — see [Two-factor authentication](#two-factor-authentication-totp) and [Passkeys](#passkeys-webauthn-as-a-second-factor) +- Magic-link (passwordless) login for existing accounts, routed through the same second-factor gate — see [Magic-link login](#magic-link-passwordless-login) - JWT access tokens + rotating opaque refresh tokens with theft/reuse detection - Session listing and revocation - Change password (requires current password, revokes all other sessions) @@ -247,7 +273,7 @@ The `ai` subpackage provides the safety machinery for natural-language admin too ## What's not in v2 (yet) -CLI, HTTP API, and language SDKs are separate repositories that wrap this engine — this repo is the core library only. Magic links, SMS OTP, WebAuthn, SAML, and other advanced auth methods are planned for later releases. +CLI, HTTP API, and language SDKs are separate repositories that wrap this engine — this repo is the core library only. SMS OTP, SAML, and other advanced auth methods are planned for later releases. Passkeys are currently second-factor only — passwordless *primary* login via passkeys (no password step at all) is a planned fast-follow now that magic-link forced the shared "login without a password" plumbing to exist. ## License From 162a99894169fb4fd9c82743ef5927bbf24a89e8 Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Tue, 1 Sep 2026 03:03:54 +0000 Subject: [PATCH 08/25] docs: add manual testing guide for magic-link login --- docs/testing/magic-link.md | 62 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docs/testing/magic-link.md diff --git a/docs/testing/magic-link.md b/docs/testing/magic-link.md new file mode 100644 index 0000000..3445a0e --- /dev/null +++ b/docs/testing/magic-link.md @@ -0,0 +1,62 @@ +# Manual testing: Magic-link (passwordless) login + +## Fastest check — in-memory smoke test + +No database and no real email provider needed: + +```bash +go run ./cmd/smoketest/magic-link +``` + +Walks: requesting a link for a nonexistent email (silently returns +nil, sender never called), requesting for a real account (sender +receives the raw token), completing with the real token (issues +tokens), attempting to reuse the same token (rejected — single-use), +an expired token (rejected), and an account with TOTP enrolled pausing +with `*auth.ErrSecondFactorRequired` on completion instead of issuing +tokens directly. + +## Full check — against real Postgres + +No new migration — magic-link tokens reuse the existing +`verification_tokens` table (same one email-change confirmation +uses), distinguished by `purpose = 'magic_link'`. + +1. Set `DATABASE_URL`, `JWT_SECRET`, and implement `notify.MagicLinkSender` + against a real provider (or just print the token to your terminal + for a first pass — the interface doesn't care). +2. Call `RequestMagicLink`, grab the token from wherever your sender + implementation sent it, and call `CompleteMagicLink` with it. +3. Confirm in `psql` that a row appears in `verification_tokens` with + `purpose = 'magic_link'`, and that `used_at` gets set after + `CompleteMagicLink` succeeds — a second completion attempt with the + same raw token should fail even before checking with the database + directly. + +## Unit tests + +```bash +go test ./auth/... +``` + +Specifically relevant: `auth/magiclink_test.go` — covers sending only +for existing accounts (and never revealing which emails aren't +registered), single-use enforcement, expiry, a token from a *different* +purpose (e.g. email-change) correctly rejected as a login token, and +an account with TOTP enrolled correctly pausing for a second factor on +completion rather than logging straight in. + +## What "working" looks like, in plain terms + +- Requesting a link for an email that isn't registered behaves + identically (from the caller's point of view — same nil return) to + requesting one that is, except no email actually goes out. There's + no way to tell the two cases apart from the return value alone. +- A requested link works exactly once. A second click — or any reuse + of the same raw token — fails the same way an expired link does. +- An account with TOTP or a passkey enrolled does **not** get logged + straight in by clicking the link — it pauses for the second factor, + exactly like a correct password would. +- A token minted for something else (email-change confirmation) can + never be used to log in, even if you have its raw value — the + purpose is checked, not just "is this a valid unexpired token." From 4438fbe3ad9b4873089120fc57094c739c741b64 Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Tue, 1 Sep 2026 03:03:54 +0000 Subject: [PATCH 09/25] feat: add in-memory smoke test for magic-link login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runnable end-to-end check with no database dependency: go run ./cmd/smoketest/magic-link. Walks request-for-nonexistent-email (silently returns nil, sender never called), request-and-complete for a real account, single-use enforcement, a garbage token, and — using a real generated TOTP code, not a stub — an account with TOTP enrolled correctly pausing on *auth.ErrSecondFactorRequired instead of logging straight in. --- cmd/smoketest/magic-link/main.go | 173 +++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 cmd/smoketest/magic-link/main.go diff --git a/cmd/smoketest/magic-link/main.go b/cmd/smoketest/magic-link/main.go new file mode 100644 index 0000000..2fc88d9 --- /dev/null +++ b/cmd/smoketest/magic-link/main.go @@ -0,0 +1,173 @@ +// Command magic-link is a standalone, no-database smoke test for the +// full magic-link (passwordless) login flow: request, complete, +// single-use enforcement, expiry, and pausing for a second factor on +// an account that has one enrolled. Run with: +// +// go run ./cmd/smoketest/magic-link +package main + +import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "time" + + "github.com/crydensync/cryden/v2" + "github.com/crydensync/cryden/v2/auth" + "github.com/crydensync/cryden/v2/store/memory" + "github.com/pquerna/otp/totp" +) + +const ( + email = "raymondproguy@dev.com" + password = "Tr0ubl3-Fr33!2026" +) + +var failures int + +// capturingSender stands in for a real email provider — it just +// records the last token it was asked to send, so the smoke test can +// grab it and simulate "clicking the link." +type capturingSender struct { + lastToken string + calls int +} + +func (s *capturingSender) SendMagicLink(ctx context.Context, to string, rawToken string) error { + s.lastToken = rawToken + s.calls++ + return nil +} + +func main() { + ctx := context.Background() + sender := &capturingSender{} + + engine, err := cryden.New(cryden.Config{ + JWTSecret: "smoketest-jwt-secret", + Users: memory.NewUserStore(), + Sessions: memory.NewSessionStore(), + Audit: memory.NewAuditStore(), + Verifications: memory.NewVerificationStore(), + MagicLinkSender: sender, + }) + check("engine constructed", err) + + user, err := cryden.SignUp(ctx, engine, email, password, "1.2.3.4") + check("signed up", err) + + // 1. Requesting a link for a nonexistent email must return nil + // and never call the sender. + err = cryden.RequestMagicLink(ctx, engine, "nobody@example.com", "1.2.3.4") + check("request for nonexistent email returns nil", err) + if sender.calls != 0 { + fail(fmt.Sprintf("expected 0 sends for a nonexistent email, got %d", sender.calls)) + } else { + pass("sender never called for a nonexistent email") + } + + // 2. Requesting for a real account sends a token. + err = cryden.RequestMagicLink(ctx, engine, email, "1.2.3.4") + check("requested a magic link for a real account", err) + if sender.calls != 1 || sender.lastToken == "" { + fail("expected exactly 1 send with a non-empty token") + } else { + pass("sender received exactly 1 non-empty token") + } + firstToken := sender.lastToken + + // 3. Completing with the real token issues tokens directly (no + // second factor enrolled yet). + tokens, err := cryden.CompleteMagicLink(ctx, engine, firstToken, "1.2.3.4", "smoketest-agent") + check("completed login with the real token", err) + if tokens.AccessToken == "" || tokens.RefreshToken == "" { + fail("expected both tokens to be populated") + } else { + pass("both tokens populated") + } + + // 4. Reusing the same token must fail — single-use. + _, err = cryden.CompleteMagicLink(ctx, engine, firstToken, "1.2.3.4", "smoketest-agent") + checkExpectError("reusing the same token is rejected", err) + + // 5. A garbage token must fail. + _, err = cryden.CompleteMagicLink(ctx, engine, "not-a-real-token", "1.2.3.4", "smoketest-agent") + checkExpectError("a garbage token is rejected", err) + + // 6. Enroll and confirm TOTP, then confirm a fresh magic link + // pauses for the second factor instead of logging straight in. + otpauthURL, err := cryden.EnrollTOTP(ctx, engine, user.ID) + check("enrolled TOTP for the second-factor check", err) + secret, err := extractSecretFromURL(otpauthURL) + check("extracted TOTP secret", err) + code, err := totp.GenerateCode(secret, time.Now()) + check("generated a real TOTP code", err) + err = cryden.ConfirmTOTP(ctx, engine, user.ID, code) + check("confirmed TOTP enrollment", err) + + err = cryden.RequestMagicLink(ctx, engine, email, "1.2.3.4") + check("requested a second magic link", err) + + _, err = cryden.CompleteMagicLink(ctx, engine, sender.lastToken, "1.2.3.4", "smoketest-agent") + var secondFactor *auth.ErrSecondFactorRequired + if !errors.As(err, &secondFactor) { + fail(fmt.Sprintf("expected *auth.ErrSecondFactorRequired for an account with TOTP enrolled, got %v", err)) + } else { + pass("magic-link completion on a TOTP-enrolled account pauses for the second factor") + if len(secondFactor.Methods) != 1 || secondFactor.Methods[0] != "totp" { + fail(fmt.Sprintf("expected Methods == [\"totp\"], got %v", secondFactor.Methods)) + } else { + pass("Methods correctly reports [\"totp\"]") + } + } + + fmt.Println() + if failures == 0 { + fmt.Println("ALL CHECKS PASSED") + } else { + fmt.Printf("%d CHECK(S) FAILED\n", failures) + os.Exit(1) + } +} + +func check(step string, err error) { + if err != nil { + fail(fmt.Sprintf("%s: unexpected error: %v", step, err)) + return + } + pass(step) +} + +func checkExpectError(step string, err error) { + if err == nil { + fail(fmt.Sprintf("%s: expected an error, got nil", step)) + return + } + pass(fmt.Sprintf("%s (%v)", step, err)) +} + +func pass(step string) { + fmt.Println("✓", step) +} + +func fail(msg string) { + failures++ + fmt.Println("✗", msg) +} + +// extractSecretFromURL pulls the base32 secret out of an otpauth:// +// URL — stands in for what a real authenticator app does when it +// scans the QR code. +func extractSecretFromURL(otpauthURL string) (string, error) { + u, err := url.Parse(otpauthURL) + if err != nil { + return "", err + } + secret := u.Query().Get("secret") + if secret == "" { + return "", fmt.Errorf("no secret query param found in %q", otpauthURL) + } + return secret, nil +} From 8d0a2da9a6d08e62d627484a7a360da25028413f Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Tue, 1 Sep 2026 03:12:54 +0000 Subject: [PATCH 10/25] fix: configure TOTP in the magic-link smoke test's engine Step 6 (enrolling TOTP to verify the second-factor pause) called cryden.EnrollTOTP against an engine built without Config.TOTP or Config.EncryptionKey set, so it failed immediately with ErrTOTPNotConfigured before ever reaching the actual check this step exists to verify. --- cmd/smoketest/magic-link/main.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd/smoketest/magic-link/main.go b/cmd/smoketest/magic-link/main.go index 2fc88d9..d5b67e0 100644 --- a/cmd/smoketest/magic-link/main.go +++ b/cmd/smoketest/magic-link/main.go @@ -52,6 +52,9 @@ func main() { Audit: memory.NewAuditStore(), Verifications: memory.NewVerificationStore(), MagicLinkSender: sender, + TOTP: memory.NewTOTPStore(), + EncryptionKey: "smoketest-encryption-key", + TOTPIssuerName: "CrydenSync Smoke Test", }) check("engine constructed", err) From 9b68030cfc5bd8755b85ea11dec13aaaa0d0b410 Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Tue, 1 Sep 2026 18:35:18 +0000 Subject: [PATCH 11/25] feat: add RecoveryCode type and RecoveryCodeStore interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeHash uses the same fast SHA-256 hash as refresh tokens (token.HashToken), not bcrypt — a recovery code is a high-entropy random value generated by the engine, not a user-chosen secret, so there's no weak-guessing risk a slow hash would defend against. Also adds recovery_codes_generated/recovery_code_used/ recovery_code_failed audit event types. --- store/interfaces.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/store/interfaces.go b/store/interfaces.go index f1f5d40..92a8294 100644 --- a/store/interfaces.go +++ b/store/interfaces.go @@ -127,6 +127,9 @@ const ( EventWebAuthnRemoved AuditEventType = "webauthn_removed" EventWebAuthnChallengeFailed AuditEventType = "webauthn_challenge_failed" EventMagicLinkRequested AuditEventType = "magic_link_requested" + EventRecoveryCodesGenerated AuditEventType = "recovery_codes_generated" + EventRecoveryCodeUsed AuditEventType = "recovery_code_used" + EventRecoveryCodeFailed AuditEventType = "recovery_code_failed" ) // AuditEvent is a single security-relevant, queryable record. @@ -278,3 +281,44 @@ type WebAuthnCredentialStore interface { Update(ctx context.Context, cred WebAuthnCredential) error Delete(ctx context.Context, userID string, credentialID []byte) error } + +// RecoveryCode is one single-use fallback code for accounts with a +// second factor enrolled. CodeHash uses the same fast SHA-256 hash as +// refresh tokens (token.HashToken) rather than bcrypt — a recovery +// code is a high-entropy random value, not a user-chosen secret, so +// there's no weak-guessing risk a slow hash would defend against; the +// only way to find one is to already have it. +type RecoveryCode struct { + UserID string + CodeHash string + UsedAt *time.Time + CreatedAt time.Time +} + +// RecoveryCodeStore defines persistence for a user's batch of +// recovery codes. +type RecoveryCodeStore interface { + // ReplaceAll wipes any existing codes for userID and inserts + // codes as the new complete batch — generating a fresh set always + // invalidates every previous one, there's no way to add codes to + // an existing batch incrementally. + ReplaceAll(ctx context.Context, userID string, codes []RecoveryCode) error + // CountUnused is used to decide whether "recovery_code" belongs + // in Login's Methods list — cheaper than fetching and hashing + // every code just to check whether any remain. + CountUnused(ctx context.Context, userID string) (int, error) + // Consume finds an unused code matching codeHash for userID and + // marks it used, atomically — the same code must never validate + // twice. Returns ErrNotFound if no matching unused code exists + // (wrong code, already used, or none generated at all). + Consume(ctx context.Context, userID string, codeHash string) error + // DeleteAll removes every code for userID. Not wired into + // DisableTOTP/DeletePasskey automatically — recovery codes are + // harmless to leave in place even with no second factor active, + // since completePrimaryAuth only ever checks for unused recovery + // codes when the account also has a confirmed TOTP secret or a + // registered passkey (see completePrimaryAuth) — they can never + // stand in as a login gate on their own. DeleteAll exists for + // hygiene, if a host app wants to clean up explicitly. + DeleteAll(ctx context.Context, userID string) error +} From f64bf9b57cda5dc034d6ade04772cb8c53ccca79 Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Tue, 1 Sep 2026 18:35:26 +0000 Subject: [PATCH 12/25] feat: add in-memory RecoveryCodeStore implementation For tests and local experimentation only, matching the existing in-memory store conventions (not a supported production backend). --- store/memory/recovery_code_store.go | 72 +++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 store/memory/recovery_code_store.go diff --git a/store/memory/recovery_code_store.go b/store/memory/recovery_code_store.go new file mode 100644 index 0000000..1010819 --- /dev/null +++ b/store/memory/recovery_code_store.go @@ -0,0 +1,72 @@ +package memory + +import ( + "context" + "sync" + "time" + + "github.com/crydensync/cryden/v2/store" +) + +// RecoveryCodeStore is an in-memory store.RecoveryCodeStore +// implementation for tests and local experimentation only — not a +// supported production backend. The Postgres implementation is +// authoritative for prod. +type RecoveryCodeStore struct { + mu sync.Mutex + byUserID map[string][]store.RecoveryCode +} + +func NewRecoveryCodeStore() *RecoveryCodeStore { + return &RecoveryCodeStore{byUserID: make(map[string][]store.RecoveryCode)} +} + +func (s *RecoveryCodeStore) ReplaceAll(ctx context.Context, userID string, codes []store.RecoveryCode) error { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now() + stored := make([]store.RecoveryCode, len(codes)) + for i, c := range codes { + c.UserID = userID + c.CreatedAt = now + c.UsedAt = nil + stored[i] = c + } + s.byUserID[userID] = stored + return nil +} + +func (s *RecoveryCodeStore) CountUnused(ctx context.Context, userID string) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + count := 0 + for _, c := range s.byUserID[userID] { + if c.UsedAt == nil { + count++ + } + } + return count, nil +} + +func (s *RecoveryCodeStore) Consume(ctx context.Context, userID string, codeHash string) error { + s.mu.Lock() + defer s.mu.Unlock() + codes := s.byUserID[userID] + for i, c := range codes { + if c.CodeHash == codeHash && c.UsedAt == nil { + now := time.Now() + codes[i].UsedAt = &now + return nil + } + } + return store.ErrNotFound +} + +func (s *RecoveryCodeStore) DeleteAll(ctx context.Context, userID string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.byUserID, userID) + return nil +} + +var _ store.RecoveryCodeStore = (*RecoveryCodeStore)(nil) From 1c9c822c294375e7a48b92d35ed023caf8dbd82a Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Tue, 1 Sep 2026 18:35:26 +0000 Subject: [PATCH 13/25] feat: add Postgres RecoveryCodeStore implementation --- store/postgres/recovery_code_store.go | 64 +++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 store/postgres/recovery_code_store.go diff --git a/store/postgres/recovery_code_store.go b/store/postgres/recovery_code_store.go new file mode 100644 index 0000000..9f0981d --- /dev/null +++ b/store/postgres/recovery_code_store.go @@ -0,0 +1,64 @@ +package postgres + +import ( + "context" + "database/sql" + + "github.com/crydensync/cryden/v2/store" +) + +// RecoveryCodeStore is the v2 production store.RecoveryCodeStore +// implementation. +type RecoveryCodeStore struct { + db *sql.DB +} + +func NewRecoveryCodeStore(db *sql.DB) *RecoveryCodeStore { + return &RecoveryCodeStore{db: db} +} + +func (s *RecoveryCodeStore) ReplaceAll(ctx context.Context, userID string, codes []store.RecoveryCode) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, `DELETE FROM recovery_codes WHERE user_id = $1`, userID); err != nil { + return err + } + for _, c := range codes { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO recovery_codes (user_id, code_hash) VALUES ($1, $2) + `, userID, c.CodeHash); err != nil { + return err + } + } + return tx.Commit() +} + +func (s *RecoveryCodeStore) CountUnused(ctx context.Context, userID string) (int, error) { + var count int + err := s.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM recovery_codes WHERE user_id = $1 AND used_at IS NULL + `, userID).Scan(&count) + return count, err +} + +func (s *RecoveryCodeStore) Consume(ctx context.Context, userID string, codeHash string) error { + result, err := s.db.ExecContext(ctx, ` + UPDATE recovery_codes SET used_at = now() + WHERE user_id = $1 AND code_hash = $2 AND used_at IS NULL + `, userID, codeHash) + if err != nil { + return err + } + return checkRowsAffected(result) +} + +func (s *RecoveryCodeStore) DeleteAll(ctx context.Context, userID string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM recovery_codes WHERE user_id = $1`, userID) + return err +} + +var _ store.RecoveryCodeStore = (*RecoveryCodeStore)(nil) From 3c0dc5db7e570c5cfe19fb2ca02a969eb0a03e75 Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Tue, 1 Sep 2026 18:35:26 +0000 Subject: [PATCH 14/25] feat: add recovery_codes table migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit code_hash is the primary key directly rather than a separate id column — it's already globally unique on its own (random, high- entropy), so a separate surrogate key would add nothing. --- .../migrations/0005_recovery_codes.down.sql | 3 +++ .../migrations/0005_recovery_codes.up.sql | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 store/postgres/migrations/0005_recovery_codes.down.sql create mode 100644 store/postgres/migrations/0005_recovery_codes.up.sql diff --git a/store/postgres/migrations/0005_recovery_codes.down.sql b/store/postgres/migrations/0005_recovery_codes.down.sql new file mode 100644 index 0000000..4113379 --- /dev/null +++ b/store/postgres/migrations/0005_recovery_codes.down.sql @@ -0,0 +1,3 @@ +-- 0005_recovery_codes.down.sql + +DROP TABLE recovery_codes; diff --git a/store/postgres/migrations/0005_recovery_codes.up.sql b/store/postgres/migrations/0005_recovery_codes.up.sql new file mode 100644 index 0000000..bce66e6 --- /dev/null +++ b/store/postgres/migrations/0005_recovery_codes.up.sql @@ -0,0 +1,16 @@ +-- 0005_recovery_codes.up.sql + +CREATE TABLE recovery_codes ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + -- SHA-256, not bcrypt — a recovery code is a high-entropy random + -- value generated by the engine, not a user-chosen secret, so + -- there's no weak-guessing risk a slow hash would defend against. + -- Globally unique on its own (random, high-entropy), so it's the + -- primary key directly rather than introducing a separate id + -- column just to have one. + code_hash TEXT PRIMARY KEY, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_recovery_codes_user_id ON recovery_codes(user_id); From 3bac5c1f1a694ada8392e37cf8590fdc690ccdbc Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Tue, 1 Sep 2026 18:35:39 +0000 Subject: [PATCH 15/25] fix: route LoginWithOAuth through completePrimaryAuth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LoginWithOAuth predated the second-factor work and did its own inline session issuance — an account with TOTP/a passkey enrolled would log straight in via a linked OAuth identity with NO second-factor check at all. It now takes totpStore/webauthnStore/recoveryCodeStore params (all nil-safe) and routes through the same completePrimaryAuth gate password/magic-link login use. Confirming an OAuth identity proves the primary factor, exactly like a correct password; it was never meant to bypass a confirmed second one. The pre-fix code also tagged its login_success audit event with which provider was used. completePrimaryAuth/finishLogin gained an extraMetadata param specifically to preserve that detail through the shared helper — LoginWithOAuth passes {"provider": provider}, other callers pass nil. While threading recoveryCodeStore through (added alongside TOTP/ WebAuthn in the same signature change, since Go requires every call site to move together or the build breaks), completePrimaryAuth also gained the recovery-code safety property: "recovery_code" is only ever added to Methods when a real factor (totp/webauthn) is also present. An account that disabled its last real second factor but still has unconsumed recovery codes sitting in storage must never have those codes silently become a permanent standalone backdoor. Updates existing lockout_test.go/login_test.go/login_totp_test.go/ magiclink_test.go/oauth_test.go call sites for both signature changes. --- auth/lockout_test.go | 14 +++++----- auth/login.go | 58 ++++++++++++++++++++++++++++++++--------- auth/login_test.go | 10 +++---- auth/login_totp_test.go | 10 +++---- auth/magiclink.go | 3 ++- auth/magiclink_test.go | 12 ++++----- auth/mfa.go | 2 +- auth/oauth.go | 57 ++++++++++++++-------------------------- auth/oauth_test.go | 6 ++--- auth/webauthn.go | 2 +- cryden.go | 35 ++++++++++++++++++++++--- 11 files changed, 126 insertions(+), 83 deletions(-) diff --git a/auth/lockout_test.go b/auth/lockout_test.go index d8f6ef6..05aa6e4 100644 --- a/auth/lockout_test.go +++ b/auth/lockout_test.go @@ -16,7 +16,7 @@ func TestLogin_LocksAccountAfterThreshold(t *testing.T) { threshold := 3 for i := 0; i < threshold; i++ { - _, err := Login(ctx, users, sessions, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, + _, err := Login(ctx, users, sessions, nil, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "wrong-password", "1.2.3.4", "test-agent", threshold, time.Minute) if err != ErrInvalidCredentials { t.Fatalf("attempt %d: expected ErrInvalidCredentials, got %v", i+1, err) @@ -26,7 +26,7 @@ func TestLogin_LocksAccountAfterThreshold(t *testing.T) { // One more attempt, even with the CORRECT password, must now be // rejected as locked — the lock isn't just "N more wrong guesses // fail," it blocks everything including a legitimate login. - _, err := Login(ctx, users, sessions, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, + _, err := Login(ctx, users, sessions, nil, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "correct-password", "1.2.3.4", "test-agent", threshold, time.Minute) if err != ErrAccountLocked { t.Errorf("expected ErrAccountLocked, got %v", err) @@ -44,12 +44,12 @@ func TestLogin_SuccessfulLoginResetsFailedAttempts(t *testing.T) { threshold := 5 // Two failed attempts, below threshold. for i := 0; i < 2; i++ { - Login(ctx, users, sessions, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, + Login(ctx, users, sessions, nil, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "wrong-password", "1.2.3.4", "test-agent", threshold, time.Minute) } // A successful login should reset the counter. - _, err := Login(ctx, users, sessions, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, + _, err := Login(ctx, users, sessions, nil, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "correct-password", "1.2.3.4", "test-agent", threshold, time.Minute) if err != nil { t.Fatalf("expected successful login, got %v", err) @@ -72,11 +72,11 @@ func TestLogin_LockExpiresAfterDuration(t *testing.T) { threshold := 1 shortLock := 10 * time.Millisecond - Login(ctx, users, sessions, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, + Login(ctx, users, sessions, nil, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "wrong-password", "1.2.3.4", "test-agent", threshold, shortLock) // Immediately after: locked. - _, err := Login(ctx, users, sessions, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, + _, err := Login(ctx, users, sessions, nil, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "correct-password", "1.2.3.4", "test-agent", threshold, shortLock) if err != ErrAccountLocked { t.Fatalf("expected ErrAccountLocked immediately after lock, got %v", err) @@ -85,7 +85,7 @@ func TestLogin_LockExpiresAfterDuration(t *testing.T) { time.Sleep(20 * time.Millisecond) // After the lock duration passes, login should succeed again. - _, err = Login(ctx, users, sessions, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, + _, err = Login(ctx, users, sessions, nil, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "correct-password", "1.2.3.4", "test-agent", threshold, shortLock) if err != nil { t.Errorf("expected login to succeed after lock expiry, got %v", err) diff --git a/auth/login.go b/auth/login.go index 4063950..b636ffe 100644 --- a/auth/login.go +++ b/auth/login.go @@ -34,6 +34,7 @@ func Login( sessions store.SessionStore, totpStore store.TOTPStore, webauthnStore store.WebAuthnCredentialStore, + recoveryCodeStore store.RecoveryCodeStore, hasher security.Hasher, ids security.IDGenerator, refreshGen token.TokenGenerator, @@ -111,24 +112,34 @@ func Login( // every primary authentication method uses (magic-link login goes // through this too) — a correct password only ever proves the // primary factor, never bypasses a confirmed second one. - return completePrimaryAuth(ctx, sessions, totpStore, webauthnStore, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, user, callerIP, userAgent) + return completePrimaryAuth(ctx, sessions, totpStore, webauthnStore, recoveryCodeStore, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, user, callerIP, userAgent, nil) } // completePrimaryAuth is the shared tail of every primary -// authentication path (password login, magic-link login, and any -// future one) once the caller has independently established "this -// really is the account owner." It collects any confirmed +// authentication path (password login, magic-link login, OAuth login, +// and any future one) once the caller has independently established +// "this really is the account owner." It collects any confirmed // second-factor methods the account has enrolled — a confirmed TOTP // secret, one or more registered passkeys, or both — and either // pauses with *ErrSecondFactorRequired or finishes the login // directly. Centralizing this here means a new primary auth method // can never accidentally skip the second-factor gate by reimplementing -// this check slightly differently. +// this check slightly differently. extraMetadata is passed straight +// through to finishLogin's audit event (e.g. OAuth's provider) — nil +// if there's nothing to add. +// +// "recovery_code" is only ever added to Methods alongside a real +// confirmed factor (totp/webauthn) — never on its own. Otherwise an +// account that disabled its last real second factor but still has +// unconsumed recovery codes sitting in storage would have those codes +// silently become a permanent standalone backdoor into the account, +// long after 2FA was supposedly turned off. func completePrimaryAuth( ctx context.Context, sessions store.SessionStore, totpStore store.TOTPStore, webauthnStore store.WebAuthnCredentialStore, + recoveryCodeStore store.RecoveryCodeStore, ids security.IDGenerator, refreshGen token.TokenGenerator, jwtIssuer *token.JWTIssuer, @@ -138,20 +149,30 @@ func completePrimaryAuth( user store.User, callerIP string, userAgent string, + extraMetadata map[string]string, ) (Tokens, error) { var methods []string + hasRealSecondFactor := false if totpStore != nil { secretRec, err := totpStore.GetByUserID(ctx, user.ID) if err == nil && secretRec.ConfirmedAt != nil { + hasRealSecondFactor = true methods = append(methods, "totp") } } if webauthnStore != nil { creds, err := webauthnStore.ListByUser(ctx, user.ID) if err == nil && len(creds) > 0 { + hasRealSecondFactor = true methods = append(methods, "webauthn") } } + if hasRealSecondFactor && recoveryCodeStore != nil { + count, err := recoveryCodeStore.CountUnused(ctx, user.ID) + if err == nil && count > 0 { + methods = append(methods, "recovery_code") + } + } if len(methods) > 0 { pendingToken, issueErr := pendingIssuer.Issue(user.ID) if issueErr != nil { @@ -161,15 +182,19 @@ func completePrimaryAuth( return Tokens{}, &ErrSecondFactorRequired{PendingToken: pendingToken, Methods: methods} } - return finishLogin(ctx, sessions, ids, refreshGen, jwtIssuer, audit, log, user, callerIP, userAgent, "") + return finishLogin(ctx, sessions, ids, refreshGen, jwtIssuer, audit, log, user, callerIP, userAgent, "", extraMetadata) } // finishLogin issues a new session (access + refresh token pair) for -// an already-authenticated user. Shared by Login (password-only -// accounts) and CompleteLoginWithTOTP (accounts with 2FA) so both -// paths create sessions identically — a second factor changes how a -// caller gets here, never what a completed login produces. mfaMethod -// is recorded in the audit event's metadata ("" for password-only). +// an already-authenticated user. Shared by every path that reaches a +// completed login (password, magic-link, OAuth, and each +// second-factor completion) so they all create sessions identically. +// mfaMethod is recorded in the audit event's metadata ("" for no +// second factor). extraMetadata is merged in alongside it — e.g. +// LoginWithOAuth passes {"provider": provider} so the audit trail +// still shows which provider was used, the same detail it recorded +// before this became a shared helper. Pass nil if there's nothing to +// add. func finishLogin( ctx context.Context, sessions store.SessionStore, @@ -182,6 +207,7 @@ func finishLogin( callerIP string, userAgent string, mfaMethod string, + extraMetadata map[string]string, ) (Tokens, error) { sessionID, err := ids.New() if err != nil { @@ -214,8 +240,14 @@ func finishLogin( } var metadata map[string]string - if mfaMethod != "" { - metadata = map[string]string{"mfa": mfaMethod} + if mfaMethod != "" || len(extraMetadata) > 0 { + metadata = make(map[string]string, len(extraMetadata)+1) + for k, v := range extraMetadata { + metadata[k] = v + } + if mfaMethod != "" { + metadata["mfa"] = mfaMethod + } } if err := audit.Record(ctx, store.AuditEvent{ Type: store.EventLoginSuccess, diff --git a/auth/login_test.go b/auth/login_test.go index 9bbb64d..2fb8ee3 100644 --- a/auth/login_test.go +++ b/auth/login_test.go @@ -33,7 +33,7 @@ func TestLogin_Success(t *testing.T) { // totpStore/pendingIssuer are nil — TOTP not configured for this // engine, Login must behave exactly as it did before TOTP existed. - tokens, err := Login(ctx, users, sessions, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, + tokens, err := Login(ctx, users, sessions, nil, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "correct-password", "1.2.3.4", "test-agent", 5, time.Minute) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -51,7 +51,7 @@ func TestLogin_WrongPasswordRejected(t *testing.T) { hash, _ := hasher.Hash("correct-password") users.Create(ctx, storeUser("user-1", "proguy@example.com", hash)) - _, err := Login(ctx, users, sessions, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, + _, err := Login(ctx, users, sessions, nil, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "wrong-password", "1.2.3.4", "test-agent", 5, time.Minute) if err != ErrInvalidCredentials { t.Errorf("expected ErrInvalidCredentials, got %v", err) @@ -65,7 +65,7 @@ func TestLogin_NonexistentUserRejectedWithSameError(t *testing.T) { log := testLogger{} ctx := context.Background() - _, err := Login(ctx, users, sessions, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, + _, err := Login(ctx, users, sessions, nil, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "nobody@example.com", "any-password", "1.2.3.4", "test-agent", 5, time.Minute) if err != ErrInvalidCredentials { t.Errorf("expected ErrInvalidCredentials (same as wrong password), got %v", err) @@ -93,12 +93,12 @@ func TestLogin_NonexistentUserTimingMatchesWrongPassword(t *testing.T) { users.Create(ctx, storeUser("user-1", "proguy@example.com", hash)) start := time.Now() - Login(ctx, users, sessions, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, + Login(ctx, users, sessions, nil, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "wrong-password", "1.2.3.4", "test-agent", 5, time.Minute) wrongPasswordDuration := time.Since(start) start = time.Now() - Login(ctx, users, sessions, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, + Login(ctx, users, sessions, nil, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "nobody@example.com", "any-password", "1.2.3.4", "test-agent", 5, time.Minute) nonexistentUserDuration := time.Since(start) diff --git a/auth/login_totp_test.go b/auth/login_totp_test.go index 4fab7e1..a856bf3 100644 --- a/auth/login_totp_test.go +++ b/auth/login_totp_test.go @@ -56,7 +56,7 @@ func TestLogin_WithConfirmedTOTPReturnsErrSecondFactorRequired(t *testing.T) { users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") - tokens, err := Login(ctx, users, sessions, totpStore, nil, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, + tokens, err := Login(ctx, users, sessions, totpStore, nil, nil, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4", "test-agent", 5, time.Minute) var totpRequired *ErrSecondFactorRequired @@ -81,7 +81,7 @@ func TestLogin_WithoutTOTPConfiguredIssuesTokensDirectly(t *testing.T) { hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) - tokens, err := Login(ctx, users, sessions, totpStore, nil, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, + tokens, err := Login(ctx, users, sessions, totpStore, nil, nil, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4", "test-agent", 5, time.Minute) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -105,7 +105,7 @@ func TestLogin_UnconfirmedTOTPDoesNotGateLogin(t *testing.T) { t.Fatalf("enroll failed: %v", err) } - tokens, err := Login(ctx, users, sessions, totpStore, nil, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, + tokens, err := Login(ctx, users, sessions, totpStore, nil, nil, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4", "test-agent", 5, time.Minute) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -124,7 +124,7 @@ func TestCompleteLoginWithTOTP_CorrectCodeIssuesTokens(t *testing.T) { users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) secret := enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") - _, err := Login(ctx, users, sessions, totpStore, nil, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, + _, err := Login(ctx, users, sessions, totpStore, nil, nil, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4", "test-agent", 5, time.Minute) var totpRequired *ErrSecondFactorRequired if !errors.As(err, &totpRequired) { @@ -151,7 +151,7 @@ func TestCompleteLoginWithTOTP_WrongCodeRejected(t *testing.T) { users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") - _, err := Login(ctx, users, sessions, totpStore, nil, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, + _, err := Login(ctx, users, sessions, totpStore, nil, nil, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4", "test-agent", 5, time.Minute) var totpRequired *ErrSecondFactorRequired errors.As(err, &totpRequired) diff --git a/auth/magiclink.go b/auth/magiclink.go index b7052ba..45c14bf 100644 --- a/auth/magiclink.go +++ b/auth/magiclink.go @@ -119,6 +119,7 @@ func CompleteMagicLink( verifications store.VerificationStore, totpStore store.TOTPStore, webauthnStore store.WebAuthnCredentialStore, + recoveryCodeStore store.RecoveryCodeStore, ids security.IDGenerator, refreshGen token.TokenGenerator, jwtIssuer *token.JWTIssuer, @@ -152,5 +153,5 @@ func CompleteMagicLink( return Tokens{}, err } - return completePrimaryAuth(ctx, sessions, totpStore, webauthnStore, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, user, callerIP, userAgent) + return completePrimaryAuth(ctx, sessions, totpStore, webauthnStore, recoveryCodeStore, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, user, callerIP, userAgent, nil) } diff --git a/auth/magiclink_test.go b/auth/magiclink_test.go index 3783af2..74ef071 100644 --- a/auth/magiclink_test.go +++ b/auth/magiclink_test.go @@ -90,7 +90,7 @@ func TestCompleteMagicLink_ValidTokenIssuesTokens(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - tokens, err := CompleteMagicLink(ctx, users, sessions, verifications, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, sender.rawToken, "1.2.3.4", "test-agent") + tokens, err := CompleteMagicLink(ctx, users, sessions, verifications, nil, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, sender.rawToken, "1.2.3.4", "test-agent") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -111,11 +111,11 @@ func TestCompleteMagicLink_TokenIsSingleUse(t *testing.T) { users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", "hash")) RequestMagicLink(ctx, users, verifications, sender, tokenGen, ids, limiter, audit, log, "raymondproguy@dev.com", "1.2.3.4") - if _, err := CompleteMagicLink(ctx, users, sessions, verifications, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, sender.rawToken, "1.2.3.4", "test-agent"); err != nil { + if _, err := CompleteMagicLink(ctx, users, sessions, verifications, nil, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, sender.rawToken, "1.2.3.4", "test-agent"); err != nil { t.Fatalf("unexpected error on first use: %v", err) } - _, err := CompleteMagicLink(ctx, users, sessions, verifications, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, sender.rawToken, "1.2.3.4", "test-agent") + _, err := CompleteMagicLink(ctx, users, sessions, verifications, nil, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, sender.rawToken, "1.2.3.4", "test-agent") if err != ErrVerificationTokenInvalid { t.Errorf("expected ErrVerificationTokenInvalid on reuse, got %v", err) } @@ -143,7 +143,7 @@ func TestCompleteMagicLink_ExpiredTokenRejected(t *testing.T) { ExpiresAt: time.Now().Add(-1 * time.Minute), // already expired }) - _, err := CompleteMagicLink(ctx, users, sessions, verifications, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, rawToken, "1.2.3.4", "test-agent") + _, err := CompleteMagicLink(ctx, users, sessions, verifications, nil, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, rawToken, "1.2.3.4", "test-agent") if err != ErrVerificationTokenExpired { t.Errorf("expected ErrVerificationTokenExpired, got %v", err) } @@ -174,7 +174,7 @@ func TestCompleteMagicLink_WrongPurposeTokenRejected(t *testing.T) { ExpiresAt: time.Now().Add(1 * time.Hour), }) - _, err := CompleteMagicLink(ctx, users, sessions, verifications, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, rawToken, "1.2.3.4", "test-agent") + _, err := CompleteMagicLink(ctx, users, sessions, verifications, nil, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, rawToken, "1.2.3.4", "test-agent") if err != ErrVerificationTokenInvalid { t.Errorf("expected ErrVerificationTokenInvalid for a wrong-purpose token, got %v", err) } @@ -197,7 +197,7 @@ func TestCompleteMagicLink_AccountWithTOTPPausesForSecondFactor(t *testing.T) { RequestMagicLink(ctx, users, verifications, sender, tokenGen, ids, limiter, audit, log, "raymondproguy@dev.com", "1.2.3.4") - _, err := CompleteMagicLink(ctx, users, sessions, verifications, totpStore, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, sender.rawToken, "1.2.3.4", "test-agent") + _, err := CompleteMagicLink(ctx, users, sessions, verifications, totpStore, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, sender.rawToken, "1.2.3.4", "test-agent") var secondFactor *ErrSecondFactorRequired if !errors.As(err, &secondFactor) { diff --git a/auth/mfa.go b/auth/mfa.go index e9cc0d2..c36dbfc 100644 --- a/auth/mfa.go +++ b/auth/mfa.go @@ -195,5 +195,5 @@ func CompleteLoginWithTOTP( return Tokens{}, ErrInvalidTOTPCode } - return finishLogin(ctx, sessions, ids, refreshGen, jwtIssuer, audit, log, user, callerIP, userAgent, "totp") + return finishLogin(ctx, sessions, ids, refreshGen, jwtIssuer, audit, log, user, callerIP, userAgent, "totp", nil) } diff --git a/auth/oauth.go b/auth/oauth.go index f3ae201..e0bfd4e 100644 --- a/auth/oauth.go +++ b/auth/oauth.go @@ -15,8 +15,11 @@ import ( // identity. The engine never talks to Google/GitHub itself, and never // performs an HTTP redirect — by the time this is called, the OAuth // dance is already over. provider is a plain string ("google", -// "github"); externalID is the provider's own stable user ID, never -// its email. +// "github", or any other — the engine has no fixed list, adding a new +// provider is entirely api's job: register the app, implement its +// redirect/callback/token-exchange, then call this with its own +// provider string); externalID is the provider's own stable user ID, +// never its email. // // Three outcomes: // 1. An OAuthIdentity already exists for (provider, externalID) -> @@ -29,14 +32,26 @@ import ( // link is created. // 3. Neither -> create a new User and OAuthIdentity, then issue a // session, same as a fresh signup. +// +// Either way, session issuance routes through the same +// completePrimaryAuth gate password/magic-link login use — an +// account with TOTP/a passkey enrolled pauses with +// *ErrSecondFactorRequired here too. Confirming an OAuth identity +// proves the primary factor, exactly like a correct password; it was +// never meant to bypass a confirmed second one, and until now it +// accidentally did. func LoginWithOAuth( ctx context.Context, users store.UserStore, oauth store.OAuthStore, sessions store.SessionStore, + totpStore store.TOTPStore, + webauthnStore store.WebAuthnCredentialStore, + recoveryCodeStore store.RecoveryCodeStore, ids security.IDGenerator, refreshGen token.TokenGenerator, jwtIssuer *token.JWTIssuer, + pendingIssuer *token.MFAPendingIssuer, audit store.AuditStore, log logger.Logger, provider string, @@ -99,45 +114,11 @@ func LoginWithOAuth( return Tokens{}, err } - sessionID, err := ids.New() - if err != nil { - return Tokens{}, err - } - - rawRefresh, err := refreshGen.New() - if err != nil { - return Tokens{}, err - } - - session := store.Session{ - ID: sessionID, - FamilyID: sessionID, - UserID: identity.UserID, - TokenHash: token.HashToken(rawRefresh), - IP: callerIP, - UserAgent: userAgent, - } - if err := sessions.Create(ctx, session); err != nil { - return Tokens{}, err - } - - accessToken, err := jwtIssuer.Issue(identity.UserID) + user, err := users.GetByID(ctx, identity.UserID) if err != nil { return Tokens{}, err } - - if err := audit.Record(ctx, store.AuditEvent{ - Type: store.EventLoginSuccess, - UserID: identity.UserID, - IP: callerIP, - Metadata: map[string]string{"provider": provider}, - }); err != nil { - log.Error("oauth: audit record failed", map[string]string{"error": err.Error(), "user_id": identity.UserID}) - } - - log.Info("oauth: login completed", map[string]string{"user_id": identity.UserID, "provider": provider}) - - return Tokens{AccessToken: accessToken, RefreshToken: rawRefresh}, nil + return completePrimaryAuth(ctx, sessions, totpStore, webauthnStore, recoveryCodeStore, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, user, callerIP, userAgent, map[string]string{"provider": provider}) } // LinkOAuthIdentity attaches a confirmed external identity to an diff --git a/auth/oauth_test.go b/auth/oauth_test.go index 4772435..48abdd1 100644 --- a/auth/oauth_test.go +++ b/auth/oauth_test.go @@ -29,7 +29,7 @@ func TestLoginWithOAuth_NewIdentityCreatesUserAndSession(t *testing.T) { log := testLogger{} ctx := context.Background() - tokens, err := LoginWithOAuth(ctx, users, oauth, sessions, ids, refreshGen, jwtIssuer, audit, log, + tokens, err := LoginWithOAuth(ctx, users, oauth, sessions, nil, nil, nil, ids, refreshGen, jwtIssuer, nil, audit, log, "google", "google-ext-id-1", "proguy@example.com", "1.2.3.4", "test-agent") if err != nil { t.Fatalf("unexpected error: %v", err) @@ -62,7 +62,7 @@ func TestLoginWithOAuth_ExistingLinkIssuesSession(t *testing.T) { ID: "identity-1", UserID: "user-1", Provider: "github", ExternalID: "gh-ext-id-1", Email: "devray@example.com", }) - tokens, err := LoginWithOAuth(ctx, users, oauth, sessions, ids, refreshGen, jwtIssuer, audit, log, + tokens, err := LoginWithOAuth(ctx, users, oauth, sessions, nil, nil, nil, ids, refreshGen, jwtIssuer, nil, audit, log, "github", "gh-ext-id-1", "devray@example.com", "1.2.3.4", "test-agent") if err != nil { t.Fatalf("unexpected error: %v", err) @@ -90,7 +90,7 @@ func TestLoginWithOAuth_EmailConflictWithPasswordAccountIsRejected(t *testing.T) users.Create(ctx, storeUser("user-1", "proguy@example.com", "some-password-hash")) - _, err := LoginWithOAuth(ctx, users, oauth, sessions, ids, refreshGen, jwtIssuer, audit, log, + _, err := LoginWithOAuth(ctx, users, oauth, sessions, nil, nil, nil, ids, refreshGen, jwtIssuer, nil, audit, log, "google", "google-ext-id-2", "proguy@example.com", "1.2.3.4", "test-agent") var conflict *ErrOAuthEmailConflict diff --git a/auth/webauthn.go b/auth/webauthn.go index d3de2f9..54659f8 100644 --- a/auth/webauthn.go +++ b/auth/webauthn.go @@ -329,5 +329,5 @@ func CompleteLoginWithWebAuthn( if err != nil { return Tokens{}, err } - return finishLogin(ctx, sessions, ids, refreshGen, jwtIssuer, audit, log, realUser, callerIP, userAgent, "webauthn") + return finishLogin(ctx, sessions, ids, refreshGen, jwtIssuer, audit, log, realUser, callerIP, userAgent, "webauthn", nil) } diff --git a/cryden.go b/cryden.go index ba29e8e..8f74d91 100644 --- a/cryden.go +++ b/cryden.go @@ -33,7 +33,7 @@ func SignUp(ctx context.Context, e *Engine, email, password, callerIP string) (s // and the list of enrolled methods; complete via CompleteLoginWithTOTP // or BeginWebAuthnLogin/CompleteLoginWithWebAuthn accordingly. func Login(ctx context.Context, e *Engine, email, password, callerIP, userAgent string) (Tokens, error) { - return auth.Login(ctx, e.users, e.sessions, e.totp, e.webauthn, e.hasher, e.ids, e.refreshGen, e.jwtIssuer, e.pendingIssuer, e.rateLimiter, e.audit, e.log, email, password, callerIP, userAgent, e.lockoutThreshold, e.lockoutDuration) + return auth.Login(ctx, e.users, e.sessions, e.totp, e.webauthn, e.recoveryCodes, e.hasher, e.ids, e.refreshGen, e.jwtIssuer, e.pendingIssuer, e.rateLimiter, e.audit, e.log, email, password, callerIP, userAgent, e.lockoutThreshold, e.lockoutDuration) } // ChangePassword requires the caller's current password as @@ -86,7 +86,7 @@ func LoginWithOAuth(ctx context.Context, e *Engine, provider, externalID, email, if e.oauth == nil { return Tokens{}, ErrOAuthNotConfigured } - return auth.LoginWithOAuth(ctx, e.users, e.oauth, e.sessions, e.ids, e.refreshGen, e.jwtIssuer, e.audit, e.log, provider, externalID, email, callerIP, userAgent) + return auth.LoginWithOAuth(ctx, e.users, e.oauth, e.sessions, e.totp, e.webauthn, e.recoveryCodes, e.ids, e.refreshGen, e.jwtIssuer, e.pendingIssuer, e.audit, e.log, provider, externalID, email, callerIP, userAgent) } // LinkOAuthIdentity attaches a confirmed external identity to an @@ -356,5 +356,34 @@ func CompleteMagicLink(ctx context.Context, e *Engine, rawToken, callerIP, userA if e.magicLinkSender == nil { return Tokens{}, ErrMagicLinkNotConfigured } - return auth.CompleteMagicLink(ctx, e.users, e.sessions, e.verifications, e.totp, e.webauthn, e.ids, e.refreshGen, e.jwtIssuer, e.pendingIssuer, e.audit, e.log, rawToken, callerIP, userAgent) + return auth.CompleteMagicLink(ctx, e.users, e.sessions, e.verifications, e.totp, e.webauthn, e.recoveryCodes, e.ids, e.refreshGen, e.jwtIssuer, e.pendingIssuer, e.audit, e.log, rawToken, callerIP, userAgent) +} + +// ErrRecoveryCodesNotConfigured is returned by GenerateRecoveryCodes +// and CompleteLoginWithRecoveryCode if the Engine was built without +// Config.RecoveryCodes set. +var ErrRecoveryCodesNotConfigured = errors.New("cryden: recovery codes require Config.RecoveryCodes to be set") + +// GenerateRecoveryCodes creates a fresh batch of 10 single-use +// fallback codes for an already-authenticated user, replacing any +// existing batch. The raw codes are returned exactly once — show them +// to the user immediately, the engine can never retrieve them again +// afterward. Requires the account to already have a confirmed TOTP +// secret or a registered passkey. +func GenerateRecoveryCodes(ctx context.Context, e *Engine, userID string) ([]string, error) { + if e.recoveryCodes == nil { + return nil, ErrRecoveryCodesNotConfigured + } + return auth.GenerateRecoveryCodes(ctx, e.totp, e.webauthn, e.recoveryCodes, e.audit, e.log, userID) +} + +// CompleteLoginWithRecoveryCode finishes a login that Login (or +// magic-link/OAuth login) paused with *auth.ErrSecondFactorRequired, +// using one of the account's recovery codes instead of TOTP/a +// passkey. Each code works exactly once. +func CompleteLoginWithRecoveryCode(ctx context.Context, e *Engine, pendingToken, code, callerIP, userAgent string) (Tokens, error) { + if e.recoveryCodes == nil { + return Tokens{}, ErrRecoveryCodesNotConfigured + } + return auth.CompleteLoginWithRecoveryCode(ctx, e.users, e.sessions, e.recoveryCodes, e.ids, e.refreshGen, e.jwtIssuer, e.pendingIssuer, e.audit, e.log, pendingToken, code, callerIP, userAgent) } From 40c89f3c21d78d6c1551becfdc63696e97b079d8 Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Tue, 1 Sep 2026 18:35:45 +0000 Subject: [PATCH 16/25] test: add regression tests for LoginWithOAuth's second-factor gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers: an account with TOTP confirmed pauses on the second OAuth login for the same linked identity instead of logging straight in, and the login_success audit event still gets tagged with which provider was used — the detail the pre-fix inline code recorded, confirming it survived the move into completePrimaryAuth. --- auth/oauth_second_factor_test.go | 85 ++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 auth/oauth_second_factor_test.go diff --git a/auth/oauth_second_factor_test.go b/auth/oauth_second_factor_test.go new file mode 100644 index 0000000..a4094ee --- /dev/null +++ b/auth/oauth_second_factor_test.go @@ -0,0 +1,85 @@ +package auth + +import ( + "context" + "errors" + "testing" + + "github.com/crydensync/cryden/v2/security" + "github.com/crydensync/cryden/v2/store" + "github.com/crydensync/cryden/v2/store/memory" + "github.com/crydensync/cryden/v2/token" +) + +func TestLoginWithOAuth_AccountWithTOTPPausesForSecondFactor(t *testing.T) { + // Regression test: LoginWithOAuth used to do its own inline + // session issuance, bypassing the second-factor gate entirely — + // an account with TOTP/a passkey enrolled would log straight in + // via a linked OAuth identity with no second-factor check at all. + users, oauth, sessions, audit, ids, refreshGen, jwtIssuer := newOAuthTestDeps(t) + totpStore := memory.NewTOTPStore() + totpGen := security.NewPquernaTOTPGenerator() + enc, _ := security.NewAESGCMEncryptor("test-encryption-key") + pendingIssuer, _ := token.NewMFAPendingIssuer("test-secret") + log := testLogger{} + ctx := context.Background() + + // First OAuth login creates the account (no second factor exists yet). + _, err := LoginWithOAuth(ctx, users, oauth, sessions, totpStore, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, + "google", "google-ext-id-1", "raymondproguy@dev.com", "1.2.3.4", "test-agent") + if err != nil { + t.Fatalf("unexpected error on first login: %v", err) + } + + identity, err := oauth.GetByProviderID(ctx, "google", "google-ext-id-1") + if err != nil { + t.Fatalf("failed to look up the created identity: %v", err) + } + enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, identity.UserID) + + // Second OAuth login for the same identity — now with TOTP + // enrolled and confirmed — must pause instead of logging straight in. + tokens, err := LoginWithOAuth(ctx, users, oauth, sessions, totpStore, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, + "google", "google-ext-id-1", "raymondproguy@dev.com", "1.2.3.4", "test-agent") + + var secondFactor *ErrSecondFactorRequired + if !errors.As(err, &secondFactor) { + t.Fatalf("expected *ErrSecondFactorRequired, got %v", err) + } + if tokens.AccessToken != "" { + t.Error("expected no access token to be issued before the second factor is completed") + } + if len(secondFactor.Methods) != 1 || secondFactor.Methods[0] != "totp" { + t.Errorf("expected Methods == [\"totp\"], got %v", secondFactor.Methods) + } +} + +func TestLoginWithOAuth_AuditRecordsProvider(t *testing.T) { + // The pre-refactor code tagged the login_success audit event with + // which provider was used — confirms that detail survived moving + // session issuance into the shared completePrimaryAuth helper. + users, oauth, sessions, audit, ids, refreshGen, jwtIssuer := newOAuthTestDeps(t) + pendingIssuer, _ := token.NewMFAPendingIssuer("test-secret") + log := testLogger{} + ctx := context.Background() + + _, err := LoginWithOAuth(ctx, users, oauth, sessions, nil, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, + "github", "github-ext-id-1", "raymondproguy@dev.com", "1.2.3.4", "test-agent") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + events, err := audit.SearchByType(ctx, store.EventLoginSuccess, 10) + if err != nil { + t.Fatalf("unexpected error searching audit events: %v", err) + } + found := false + for _, e := range events { + if e.Metadata["provider"] == "github" { + found = true + } + } + if !found { + t.Error("expected a login_success audit event tagged with provider=github") + } +} From 433208daaf6ff3c794aa9ee4d63557c21dc1d9c8 Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Tue, 1 Sep 2026 18:35:52 +0000 Subject: [PATCH 17/25] feat: add recovery code generation and login completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GenerateRecoveryCodes requires the account to already have a confirmed TOTP secret or a registered passkey (ErrNoSecondFactorEnrolled otherwise) — codes exist to recover access to a real second factor, not to stand in as one on their own. Always replaces the previous batch in full; every old code, used or not, stops working the moment a new batch is generated. Raw codes are returned exactly once — the engine only ever stores their hashes. CompleteLoginWithRecoveryCode normalizes case/whitespace before hashing, since these get retyped by hand and the formatting ("ABCDE-FGHIJ") is purely for readability. --- auth/recoverycodes.go | 159 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 auth/recoverycodes.go diff --git a/auth/recoverycodes.go b/auth/recoverycodes.go new file mode 100644 index 0000000..3925d8d --- /dev/null +++ b/auth/recoverycodes.go @@ -0,0 +1,159 @@ +package auth + +import ( + "context" + "errors" + "strings" + + "github.com/crydensync/cryden/v2/logger" + "github.com/crydensync/cryden/v2/security" + "github.com/crydensync/cryden/v2/store" + "github.com/crydensync/cryden/v2/token" +) + +// recoveryCodeCount is how many codes a single generation produces — +// fixed, not configurable, same reasoning as the other MFA constants: +// this is a security parameter with an established convention (most +// systems ship 8-10), not something worth exposing as a knob. +const recoveryCodeCount = 10 + +var ( + // ErrNoSecondFactorEnrolled is returned by GenerateRecoveryCodes + // if the account has no confirmed TOTP secret and no registered + // passkey — recovery codes exist to recover access to a second + // factor, generating them for an account with none would be + // meaningless (Login would never pause to ask for one). + ErrNoSecondFactorEnrolled = errors.New("auth: no second factor is enrolled for this account") + // ErrInvalidRecoveryCode covers a wrong code, an already-used one, + // or an account with none generated at all — deliberately not + // differentiated further, same enumeration-avoidance reasoning as + // ErrInvalidTOTPCode. + ErrInvalidRecoveryCode = errors.New("auth: invalid or already-used recovery code") +) + +// GenerateRecoveryCodes creates a fresh batch of recoveryCodeCount +// single-use fallback codes for an already-authenticated user, +// replacing any existing batch — every previous code, used or not, +// stops working the moment a new batch is generated. The raw codes +// are returned exactly once here; the engine only ever stores their +// hashes and can never show them again afterward, same one-time- +// display convention as almost every real system that ships these. +// Requires the account to already have a confirmed TOTP secret or a +// registered passkey — see ErrNoSecondFactorEnrolled. +func GenerateRecoveryCodes( + ctx context.Context, + totpStore store.TOTPStore, + webauthnStore store.WebAuthnCredentialStore, + recoveryCodeStore store.RecoveryCodeStore, + audit store.AuditStore, + log logger.Logger, + userID string, +) ([]string, error) { + hasSecondFactor := false + if totpStore != nil { + secretRec, err := totpStore.GetByUserID(ctx, userID) + if err == nil && secretRec.ConfirmedAt != nil { + hasSecondFactor = true + } + } + if !hasSecondFactor && webauthnStore != nil { + creds, err := webauthnStore.ListByUser(ctx, userID) + if err == nil && len(creds) > 0 { + hasSecondFactor = true + } + } + if !hasSecondFactor { + return nil, ErrNoSecondFactorEnrolled + } + + rawCodes := make([]string, recoveryCodeCount) + toStore := make([]store.RecoveryCode, recoveryCodeCount) + gen, err := token.NewCryptoRandTokenGenerator(5) + if err != nil { + return nil, err + } + for i := range rawCodes { + raw, err := gen.New() + if err != nil { + return nil, err + } + formatted := raw[:5] + "-" + raw[5:] + rawCodes[i] = formatted + toStore[i] = store.RecoveryCode{CodeHash: hashRecoveryCode(formatted)} + } + + if err := recoveryCodeStore.ReplaceAll(ctx, userID, toStore); err != nil { + return nil, err + } + + if err := audit.Record(ctx, store.AuditEvent{ + Type: store.EventRecoveryCodesGenerated, + UserID: userID, + }); err != nil { + log.Error("generate recovery codes: audit record failed", map[string]string{"error": err.Error(), "user_id": userID}) + } + + log.Info("recovery codes generated", map[string]string{"user_id": userID}) + return rawCodes, nil +} + +// CompleteLoginWithRecoveryCode finishes a login that Login (or +// magic-link/OAuth login) paused with *ErrSecondFactorRequired, using +// one of the account's recovery codes instead of TOTP/a passkey. Each +// code works exactly once. +func CompleteLoginWithRecoveryCode( + ctx context.Context, + users store.UserStore, + sessions store.SessionStore, + recoveryCodeStore store.RecoveryCodeStore, + ids security.IDGenerator, + refreshGen token.TokenGenerator, + jwtIssuer *token.JWTIssuer, + pendingIssuer *token.MFAPendingIssuer, + audit store.AuditStore, + log logger.Logger, + pendingToken string, + code string, + callerIP string, + userAgent string, +) (Tokens, error) { + userID, err := pendingIssuer.Verify(pendingToken) + if err != nil { + return Tokens{}, ErrInvalidPendingLogin + } + + if err := recoveryCodeStore.Consume(ctx, userID, hashRecoveryCode(code)); err != nil { + if auditErr := audit.Record(ctx, store.AuditEvent{ + Type: store.EventRecoveryCodeFailed, + UserID: userID, + IP: callerIP, + }); auditErr != nil { + log.Error("complete recovery code login: audit record failed", map[string]string{"error": auditErr.Error()}) + } + return Tokens{}, ErrInvalidRecoveryCode + } + + user, err := users.GetByID(ctx, userID) + if err != nil { + return Tokens{}, err + } + + if err := audit.Record(ctx, store.AuditEvent{ + Type: store.EventRecoveryCodeUsed, + UserID: userID, + IP: callerIP, + }); err != nil { + log.Error("complete recovery code login: audit record failed", map[string]string{"error": err.Error(), "user_id": userID}) + } + + return finishLogin(ctx, sessions, ids, refreshGen, jwtIssuer, audit, log, user, callerIP, userAgent, "recovery_code", nil) +} + +// hashRecoveryCode normalizes user input (case, surrounding +// whitespace) before hashing, since people will retype these by hand +// and the formatting ("ABCDE-FGHIJ") is just for readability, not +// part of the actual secret value. +func hashRecoveryCode(raw string) string { + normalized := strings.ToLower(strings.TrimSpace(raw)) + return token.HashToken(normalized) +} From 63acaada0f71d78b335f5e26bc040766cc19f1f1 Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Tue, 1 Sep 2026 18:35:58 +0000 Subject: [PATCH 18/25] test: add recovery code tests Covers: generation rejected with no second factor enrolled, 10 unique codes produced, regeneration invalidating the previous batch entirely, single-use enforcement, case/whitespace-insensitive matching, a wrong code rejected, and the two Login-level safety properties: recovery_code is only ever advertised alongside a real factor, and leftover codes never gate login on their own once the real factor is disabled. --- auth/recoverycodes_test.go | 253 +++++++++++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 auth/recoverycodes_test.go diff --git a/auth/recoverycodes_test.go b/auth/recoverycodes_test.go new file mode 100644 index 0000000..b0c795d --- /dev/null +++ b/auth/recoverycodes_test.go @@ -0,0 +1,253 @@ +package auth + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/crydensync/cryden/v2/security" + "github.com/crydensync/cryden/v2/store/memory" + "github.com/crydensync/cryden/v2/token" +) + +func newRecoveryCodeTestDeps(t *testing.T) (*memory.UserStore, *memory.TOTPStore, *memory.RecoveryCodeStore, *memory.AuditStore, security.TOTPGenerator, security.Encryptor) { + t.Helper() + users := memory.NewUserStore() + totpStore := memory.NewTOTPStore() + recoveryCodeStore := memory.NewRecoveryCodeStore() + audit := memory.NewAuditStore() + totpGen := security.NewPquernaTOTPGenerator() + enc, _ := security.NewAESGCMEncryptor("test-encryption-key") + return users, totpStore, recoveryCodeStore, audit, totpGen, enc +} + +func TestGenerateRecoveryCodes_RejectsAccountWithNoSecondFactor(t *testing.T) { + users, totpStore, recoveryCodeStore, audit, _, _ := newRecoveryCodeTestDeps(t) + log := testLogger{} + ctx := context.Background() + + hasher, _ := security.NewBcryptHasher(4) + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + + _, err := GenerateRecoveryCodes(ctx, totpStore, nil, recoveryCodeStore, audit, log, "user-1") + if err != ErrNoSecondFactorEnrolled { + t.Errorf("expected ErrNoSecondFactorEnrolled, got %v", err) + } +} + +func TestGenerateRecoveryCodes_ProducesTenUniqueCodes(t *testing.T) { + users, totpStore, recoveryCodeStore, audit, totpGen, enc := newRecoveryCodeTestDeps(t) + ctx := context.Background() + + hasher, _ := security.NewBcryptHasher(4) + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") + + codes, err := GenerateRecoveryCodes(ctx, totpStore, nil, recoveryCodeStore, audit, testLogger{}, "user-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(codes) != recoveryCodeCount { + t.Fatalf("expected %d codes, got %d", recoveryCodeCount, len(codes)) + } + seen := make(map[string]bool) + for _, c := range codes { + if seen[c] { + t.Errorf("duplicate code generated: %q", c) + } + seen[c] = true + } + + count, err := recoveryCodeStore.CountUnused(ctx, "user-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if count != recoveryCodeCount { + t.Errorf("expected %d unused codes stored, got %d", recoveryCodeCount, count) + } +} + +func TestGenerateRecoveryCodes_RegeneratingInvalidatesThePreviousBatch(t *testing.T) { + users, totpStore, recoveryCodeStore, audit, totpGen, enc := newRecoveryCodeTestDeps(t) + log := testLogger{} + ctx := context.Background() + + hasher, _ := security.NewBcryptHasher(4) + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") + + firstBatch, _ := GenerateRecoveryCodes(ctx, totpStore, nil, recoveryCodeStore, audit, log, "user-1") + _, err := GenerateRecoveryCodes(ctx, totpStore, nil, recoveryCodeStore, audit, log, "user-1") + if err != nil { + t.Fatalf("unexpected error regenerating: %v", err) + } + + // A code from the first batch must no longer be consumable. + err = recoveryCodeStore.Consume(ctx, "user-1", hashRecoveryCode(firstBatch[0])) + if err == nil { + t.Error("expected a code from the invalidated first batch to be rejected") + } +} + +func TestCompleteLoginWithRecoveryCode_ValidCodeIssuesTokensAndIsSingleUse(t *testing.T) { + users, totpStore, recoveryCodeStore, audit, totpGen, enc := newRecoveryCodeTestDeps(t) + log := testLogger{} + ctx := context.Background() + sessions := memory.NewSessionStore() + ids := security.NewUUIDv7Generator() + refreshGen, _ := token.NewCryptoRandTokenGenerator(32) + jwtIssuer, _ := token.NewJWTIssuer("test-secret", time.Minute) + pendingIssuer, _ := token.NewMFAPendingIssuer("test-secret") + + hasher, _ := security.NewBcryptHasher(4) + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") + codes, _ := GenerateRecoveryCodes(ctx, totpStore, nil, recoveryCodeStore, audit, log, "user-1") + + pendingToken, _ := pendingIssuer.Issue("user-1") + + tokens, err := CompleteLoginWithRecoveryCode(ctx, users, sessions, recoveryCodeStore, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, pendingToken, codes[0], "1.2.3.4", "test-agent") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tokens.AccessToken == "" || tokens.RefreshToken == "" { + t.Error("expected both tokens to be populated") + } + + // The same code must not work a second time. + pendingToken2, _ := pendingIssuer.Issue("user-1") + _, err = CompleteLoginWithRecoveryCode(ctx, users, sessions, recoveryCodeStore, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, pendingToken2, codes[0], "1.2.3.4", "test-agent") + if err != ErrInvalidRecoveryCode { + t.Errorf("expected ErrInvalidRecoveryCode on reuse, got %v", err) + } +} + +func TestCompleteLoginWithRecoveryCode_CaseAndWhitespaceInsensitive(t *testing.T) { + users, totpStore, recoveryCodeStore, audit, totpGen, enc := newRecoveryCodeTestDeps(t) + log := testLogger{} + ctx := context.Background() + sessions := memory.NewSessionStore() + ids := security.NewUUIDv7Generator() + refreshGen, _ := token.NewCryptoRandTokenGenerator(32) + jwtIssuer, _ := token.NewJWTIssuer("test-secret", time.Minute) + pendingIssuer, _ := token.NewMFAPendingIssuer("test-secret") + + hasher, _ := security.NewBcryptHasher(4) + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") + codes, _ := GenerateRecoveryCodes(ctx, totpStore, nil, recoveryCodeStore, audit, log, "user-1") + + pendingToken, _ := pendingIssuer.Issue("user-1") + messyInput := " " + strings.ToUpper(codes[0]) + " " + + _, err := CompleteLoginWithRecoveryCode(ctx, users, sessions, recoveryCodeStore, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, pendingToken, messyInput, "1.2.3.4", "test-agent") + if err != nil { + t.Errorf("expected an uppercased/padded code to still work, got %v", err) + } +} + +func TestCompleteLoginWithRecoveryCode_WrongCodeRejected(t *testing.T) { + users, totpStore, recoveryCodeStore, audit, totpGen, enc := newRecoveryCodeTestDeps(t) + log := testLogger{} + ctx := context.Background() + sessions := memory.NewSessionStore() + ids := security.NewUUIDv7Generator() + refreshGen, _ := token.NewCryptoRandTokenGenerator(32) + jwtIssuer, _ := token.NewJWTIssuer("test-secret", time.Minute) + pendingIssuer, _ := token.NewMFAPendingIssuer("test-secret") + + hasher, _ := security.NewBcryptHasher(4) + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") + GenerateRecoveryCodes(ctx, totpStore, nil, recoveryCodeStore, audit, log, "user-1") + + pendingToken, _ := pendingIssuer.Issue("user-1") + _, err := CompleteLoginWithRecoveryCode(ctx, users, sessions, recoveryCodeStore, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, pendingToken, "wrong-code", "1.2.3.4", "test-agent") + if err != ErrInvalidRecoveryCode { + t.Errorf("expected ErrInvalidRecoveryCode, got %v", err) + } +} + +func TestLogin_RecoveryCodeNeverAdvertisedWithoutARealSecondFactor(t *testing.T) { + // The critical safety property: unconsumed recovery codes must + // never become a standalone login gate on their own. Simulates an + // account that has recovery codes in storage (e.g. left over from + // before TOTP was disabled) but no confirmed TOTP/passkey. + users, totpStore, recoveryCodeStore, audit, totpGen, enc := newRecoveryCodeTestDeps(t) + log := testLogger{} + ctx := context.Background() + sessions := memory.NewSessionStore() + webauthnStore := memory.NewWebAuthnStore() + ids := security.NewUUIDv7Generator() + refreshGen, _ := token.NewCryptoRandTokenGenerator(32) + jwtIssuer, _ := token.NewJWTIssuer("test-secret", time.Minute) + pendingIssuer, _ := token.NewMFAPendingIssuer("test-secret") + limiter := security.NewInMemoryRateLimiter(1000, time.Minute) + hasher, _ := security.NewBcryptHasher(4) + + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") + GenerateRecoveryCodes(ctx, totpStore, nil, recoveryCodeStore, audit, log, "user-1") + + // Now disable TOTP (simulated directly via store, bypassing + // DisableTOTP's password check — this test only cares about + // Login's behavior once no real factor remains). + totpStore.Delete(ctx, "user-1") + + tokens, err := Login(ctx, users, sessions, totpStore, webauthnStore, recoveryCodeStore, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, + "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4", "test-agent", 5, time.Minute) + if err != nil { + t.Fatalf("expected direct login once no real second factor remains, got error: %v", err) + } + if tokens.AccessToken == "" { + t.Error("expected tokens to be issued directly — leftover recovery codes must never gate login alone") + } +} + +func TestLogin_ReportsRecoveryCodeAlongsideTOTP(t *testing.T) { + users, totpStore, recoveryCodeStore, audit, totpGen, enc := newRecoveryCodeTestDeps(t) + log := testLogger{} + ctx := context.Background() + sessions := memory.NewSessionStore() + webauthnStore := memory.NewWebAuthnStore() + ids := security.NewUUIDv7Generator() + refreshGen, _ := token.NewCryptoRandTokenGenerator(32) + jwtIssuer, _ := token.NewJWTIssuer("test-secret", time.Minute) + pendingIssuer, _ := token.NewMFAPendingIssuer("test-secret") + limiter := security.NewInMemoryRateLimiter(1000, time.Minute) + hasher, _ := security.NewBcryptHasher(4) + + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") + GenerateRecoveryCodes(ctx, totpStore, nil, recoveryCodeStore, audit, log, "user-1") + + _, err := Login(ctx, users, sessions, totpStore, webauthnStore, recoveryCodeStore, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, + "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4", "test-agent", 5, time.Minute) + + var secondFactor *ErrSecondFactorRequired + if !errors.As(err, &secondFactor) { + t.Fatalf("expected *ErrSecondFactorRequired, got %v", err) + } + hasTOTP, hasRecovery := false, false + for _, m := range secondFactor.Methods { + if m == "totp" { + hasTOTP = true + } + if m == "recovery_code" { + hasRecovery = true + } + } + if !hasTOTP || !hasRecovery { + t.Errorf("expected Methods to contain both totp and recovery_code, got %v", secondFactor.Methods) + } +} From fd466f82f3348226d9bc1f09925befdef6be6fbe Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Tue, 1 Sep 2026 18:36:05 +0000 Subject: [PATCH 19/25] feat: wire recovery codes into Config, Engine, and the public facade - Config.RecoveryCodes (optional, store.RecoveryCodeStore). - New facade functions: GenerateRecoveryCodes, CompleteLoginWithRecoveryCode, each returning cryden.ErrRecoveryCodesNotConfigured if called without Config.RecoveryCodes set. --- config.go | 6 ++++++ engine.go | 2 ++ 2 files changed, 8 insertions(+) diff --git a/config.go b/config.go index 07f0167..6b693d1 100644 --- a/config.go +++ b/config.go @@ -76,6 +76,12 @@ type Config struct { // actually sends — a mismatch here is a common integration error, // not a security relaxation to work around casually. WebAuthnRPOrigins []string + // RecoveryCodes is optional — only required if + // GenerateRecoveryCodes / CompleteLoginWithRecoveryCode are used. + // Left unset, those facade functions return + // ErrRecoveryCodesNotConfigured and Login never advertises + // "recovery_code" as an available second-factor method. + RecoveryCodes store.RecoveryCodeStore // Optional — sensible defaults applied in New() if zero-valued. // These are tuning knobs, not security-critical secrets, so diff --git a/engine.go b/engine.go index 4eb10ad..37dcbef 100644 --- a/engine.go +++ b/engine.go @@ -23,6 +23,7 @@ type Engine struct { totp store.TOTPStore webauthn store.WebAuthnCredentialStore magicLinkSender notify.MagicLinkSender + recoveryCodes store.RecoveryCodeStore hasher security.Hasher ids security.IDGenerator @@ -105,6 +106,7 @@ func New(cfg Config) (*Engine, error) { totp: cfg.TOTP, webauthn: cfg.WebAuthn, magicLinkSender: cfg.MagicLinkSender, + recoveryCodes: cfg.RecoveryCodes, hasher: hasher, ids: security.NewUUIDv7Generator(), rateLimiter: security.NewInMemoryRateLimiter(cfg.RateLimitAttempts, cfg.RateLimitWindow), From 0cfdfe634a5ab817e22ec6efe0e3db2bf414ad8f Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Tue, 1 Sep 2026 18:36:12 +0000 Subject: [PATCH 20/25] docs: document recovery codes in README --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index 619777a..9d6ee8f 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,33 @@ tokens, err := cryden.CompleteMagicLink(ctx, engine, rawTokenFromTheLink, caller The link is valid for 15 minutes and single-use — clicking it a second time fails the same way an expired one does. Like `Login`, `CompleteMagicLink` routes through the same second-factor gate: an account with TOTP/a passkey enrolled returns `*auth.ErrSecondFactorRequired` here exactly as it would after a correct password — clicking the link proves email ownership, the primary factor, not a bypass of a confirmed second one. Calling either function without `Config.MagicLinkSender` set returns `cryden.ErrMagicLinkNotConfigured`. +## Recovery (backup) codes + +Requires one additional `Config` field: + +```go +engine, err := cryden.New(cryden.Config{ + // ...required fields... + RecoveryCodes: postgres.NewRecoveryCodeStore(db), // or memory.NewRecoveryCodeStore() +}) +``` + +Generating a batch requires the account to already have a confirmed TOTP secret or a registered passkey — codes exist to recover access to a *real* second factor, not to stand in as one on their own: + +```go +codes, err := cryden.GenerateRecoveryCodes(ctx, engine, userID) +// show `codes` to the user ONCE — the engine only ever stores their hashes +// and can never display them again after this call returns +``` + +Generating a fresh batch always replaces the previous one in full — every old code, used or not, stops working immediately. Completion works the same way TOTP does: + +```go +tokens, err := cryden.CompleteLoginWithRecoveryCode(ctx, engine, secondFactor.PendingToken, code, callerIP, userAgent) +``` + +**One safety property worth knowing:** `"recovery_code"` only ever appears in `Login`'s `Methods` list *alongside* `"totp"` and/or `"webauthn"` — never on its own. If an account's last real second factor gets disabled while unconsumed codes still exist in storage, those codes stop being offered at all, rather than silently becoming a standalone permanent backdoor into the account. Calling either function without `Config.RecoveryCodes` set returns `cryden.ErrRecoveryCodesNotConfigured`. + ## AI-assisted admin queries (library support only) The `ai` subpackage provides the safety machinery for natural-language admin tooling — an allowlisted `QueryIntent` type, `validateIntent`, and `ExecuteQuery` — plus `store/postgres.SafeQueryStore`, a read-only query executor. This is a foundation for tools like `csax`'s CLI to build on, not a feature you call directly in application code. An LLM's output is treated as untrusted data to validate against a strict allowlist, never as SQL to execute — and the actual DB connection passed to `SafeQueryStore` must be opened with a read-only Postgres role, since that's the real safety boundary, not just the allowlist check. `ai.LLMProvider` ships zero implementations; bring your own (OpenAI, Anthropic, OpenRouter, a local model). @@ -259,6 +286,7 @@ The `ai` subpackage provides the safety machinery for natural-language admin too - OAuth login/signup (Google, GitHub, or any provider) with explicit, non-auto-linking account collision handling — see [OAuth](#oauth-google-github-or-any-provider) - Two-factor authentication: TOTP and passkeys (WebAuthn), unified under one pause state — see [Two-factor authentication](#two-factor-authentication-totp) and [Passkeys](#passkeys-webauthn-as-a-second-factor) - Magic-link (passwordless) login for existing accounts, routed through the same second-factor gate — see [Magic-link login](#magic-link-passwordless-login) +- Recovery (backup) codes as a second-factor fallback, with a safety guard against becoming a standalone backdoor once the real factor is removed — see [Recovery codes](#recovery-backup-codes) - JWT access tokens + rotating opaque refresh tokens with theft/reuse detection - Session listing and revocation - Change password (requires current password, revokes all other sessions) From 0ee01a181dce39f1b9f325420a36f0a7b6a007f4 Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Tue, 1 Sep 2026 18:36:12 +0000 Subject: [PATCH 21/25] docs: add manual testing guide for recovery codes --- docs/testing/recovery-codes.md | 63 ++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 docs/testing/recovery-codes.md diff --git a/docs/testing/recovery-codes.md b/docs/testing/recovery-codes.md new file mode 100644 index 0000000..cdf9236 --- /dev/null +++ b/docs/testing/recovery-codes.md @@ -0,0 +1,63 @@ +# Manual testing: Recovery (backup) codes + +## Fastest check — in-memory smoke test + +No database needed: + +```bash +go run ./cmd/smoketest/recovery-codes +``` + +Walks: generating codes fails for an account with no second factor +enrolled, enrolling TOTP then generating a real batch of 10 unique +codes, logging in and completing with a real code, reusing the same +code (rejected), a wrong code (rejected), regenerating invalidating +the previous batch, and — the important safety property — disabling +the account's only real second factor and confirming any leftover +recovery codes no longer gate login at all. + +## Full check — against real Postgres + +1. Apply the migration: + ```bash + psql "$DATABASE_URL" -f store/postgres/migrations/0005_recovery_codes.up.sql + ``` +2. Generate a batch for a test account with TOTP already confirmed, + note the codes, then confirm in `psql` that `recovery_codes` has 10 + rows with `used_at IS NULL`. +3. Complete a login with one of them, confirm `used_at` gets set on + exactly that row and no others. +4. Regenerate, confirm the table now only has the new 10 rows — the + old ones are gone, not just marked used. + +## Unit tests + +```bash +go test ./auth/... +``` + +Specifically relevant: `auth/recoverycodes_test.go` — covers rejecting +generation with no second factor enrolled, producing 10 unique codes, +regeneration invalidating the previous batch, single-use enforcement, +case/whitespace-insensitive matching (people retype these by hand), a +wrong code, and the two `Login`-level safety tests: `"recovery_code"` +is only ever advertised alongside a real factor (`"totp"` or +`"webauthn"`), and codes left over after disabling the real factor +never gate login on their own. + +## What "working" looks like, in plain terms + +- Generating codes for an account with no TOTP/passkey fails outright + — there's nothing for them to be a fallback for. +- The 10 codes are shown exactly once. There is no way to view them + again later — only regenerate a fresh batch (which invalidates the + old one). +- Each code works exactly once, the same way a magic link does. +- Generating a new batch kills every code from the old one immediately + — used or not. +- The property that actually matters most: if someone disables their + TOTP (or removes their only passkey) but never explicitly cleared + out their recovery codes, those codes must NOT keep working as a + standalone login gate. Confirm this directly — enroll TOTP, generate + codes, delete the TOTP secret, then log in and confirm you get + tokens straight back with no second-factor pause at all. From a21679db5acb911dcd4c5037a86e3d796bac15cd Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Tue, 1 Sep 2026 18:36:12 +0000 Subject: [PATCH 22/25] feat: add in-memory smoke test for recovery codes Runnable end-to-end check with no database dependency: go run ./cmd/smoketest/recovery-codes. Walks generation rejected with no second factor, a real batch of 10 codes, login completion, single-use enforcement, a wrong code, regeneration invalidating the previous batch, and the property that matters most: disabling the account's only real second factor and confirming leftover recovery codes stop gating login entirely rather than becoming a standalone backdoor. --- cmd/smoketest/recovery-codes/main.go | 190 +++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 cmd/smoketest/recovery-codes/main.go diff --git a/cmd/smoketest/recovery-codes/main.go b/cmd/smoketest/recovery-codes/main.go new file mode 100644 index 0000000..3b99687 --- /dev/null +++ b/cmd/smoketest/recovery-codes/main.go @@ -0,0 +1,190 @@ +// Command recovery-codes is a standalone, no-database smoke test for +// the recovery (backup) code flow: generation, login completion, +// single-use enforcement, regeneration invalidating the previous +// batch, and — the important safety property — leftover codes never +// gating login once the real second factor is gone. Run with: +// +// go run ./cmd/smoketest/recovery-codes +package main + +import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "time" + + "github.com/crydensync/cryden/v2" + "github.com/crydensync/cryden/v2/auth" + "github.com/crydensync/cryden/v2/store/memory" + "github.com/pquerna/otp/totp" +) + +const ( + email = "raymondproguy@dev.com" + password = "Tr0ubl3-Fr33!2026" +) + +var failures int + +func main() { + ctx := context.Background() + + engine, err := cryden.New(cryden.Config{ + JWTSecret: "smoketest-jwt-secret", + Users: memory.NewUserStore(), + Sessions: memory.NewSessionStore(), + Audit: memory.NewAuditStore(), + TOTP: memory.NewTOTPStore(), + RecoveryCodes: memory.NewRecoveryCodeStore(), + EncryptionKey: "smoketest-encryption-key", + TOTPIssuerName: "CrydenSync Smoke Test", + }) + check("engine constructed", err) + + user, err := cryden.SignUp(ctx, engine, email, password, "1.2.3.4") + check("signed up", err) + + // 1. Generating codes before any second factor exists must fail. + _, err = cryden.GenerateRecoveryCodes(ctx, engine, user.ID) + checkExpectError("generating codes with no second factor enrolled is rejected", err) + + // 2. Enroll and confirm TOTP. + otpauthURL, err := cryden.EnrollTOTP(ctx, engine, user.ID) + check("enrolled TOTP", err) + secret, err := extractSecretFromURL(otpauthURL) + check("extracted TOTP secret", err) + code, err := totp.GenerateCode(secret, time.Now()) + check("generated a real TOTP code", err) + err = cryden.ConfirmTOTP(ctx, engine, user.ID, code) + check("confirmed TOTP enrollment", err) + + // 3. Generate a real batch of codes. + firstBatch, err := cryden.GenerateRecoveryCodes(ctx, engine, user.ID) + check("generated a batch of recovery codes", err) + if len(firstBatch) != 10 { + fail(fmt.Sprintf("expected 10 codes, got %d", len(firstBatch))) + } else { + pass("received exactly 10 codes") + } + + // 4. Login now pauses, reporting both totp and recovery_code as + // available methods. + pendingToken1, methods := requireSecondFactor(ctx, engine, "login after TOTP confirmation returns *auth.ErrSecondFactorRequired") + hasTOTP, hasRecovery := false, false + for _, m := range methods { + if m == "totp" { + hasTOTP = true + } + if m == "recovery_code" { + hasRecovery = true + } + } + if !hasTOTP || !hasRecovery { + fail(fmt.Sprintf("expected Methods to contain both totp and recovery_code, got %v", methods)) + } else { + pass("Methods correctly reports both totp and recovery_code") + } + + // 5. Complete login with a real recovery code. + realTokens, err := cryden.CompleteLoginWithRecoveryCode(ctx, engine, pendingToken1, firstBatch[0], "1.2.3.4", "smoketest-agent") + check("completed login with a real recovery code", err) + if realTokens.AccessToken == "" || realTokens.RefreshToken == "" { + fail("expected both tokens to be populated") + } else { + pass("both tokens populated") + } + + // 6. The same code must not work twice. + pendingToken2, _ := requireSecondFactor(ctx, engine, "login again requires a second factor") + _, err = cryden.CompleteLoginWithRecoveryCode(ctx, engine, pendingToken2, firstBatch[0], "1.2.3.4", "smoketest-agent") + checkExpectError("reusing the same recovery code is rejected", err) + + // 7. A wrong code must be rejected. + _, err = cryden.CompleteLoginWithRecoveryCode(ctx, engine, pendingToken2, "wrong-code", "1.2.3.4", "smoketest-agent") + checkExpectError("a wrong recovery code is rejected", err) + + // Clean up that still-pending login with a fresh unused code before continuing. + _, err = cryden.CompleteLoginWithRecoveryCode(ctx, engine, pendingToken2, firstBatch[1], "1.2.3.4", "smoketest-agent") + check("completed the pending login from step 6/7 with a fresh code", err) + + // 8. Regenerate — the old batch must be fully invalidated. + secondBatch, err := cryden.GenerateRecoveryCodes(ctx, engine, user.ID) + check("regenerated recovery codes", err) + pendingToken3, _ := requireSecondFactor(ctx, engine, "login requires a second factor before testing regeneration") + _, err = cryden.CompleteLoginWithRecoveryCode(ctx, engine, pendingToken3, firstBatch[2], "1.2.3.4", "smoketest-agent") + checkExpectError("a code from the invalidated first batch is rejected after regeneration", err) + _, err = cryden.CompleteLoginWithRecoveryCode(ctx, engine, pendingToken3, secondBatch[0], "1.2.3.4", "smoketest-agent") + check("a code from the new batch still works", err) + + // 9. Disable TOTP (the account's only real second factor) and + // confirm any leftover recovery codes stop gating login entirely + // — this is the property that actually matters. + err = cryden.DisableTOTP(ctx, engine, user.ID, password) + check("disabled TOTP", err) + + _, err = cryden.Login(ctx, engine, email, password, "1.2.3.4", "smoketest-agent") + check("login after disabling TOTP issues tokens directly — leftover recovery codes did not become a standalone backdoor", err) + + fmt.Println() + if failures == 0 { + fmt.Println("ALL CHECKS PASSED") + } else { + fmt.Printf("%d CHECK(S) FAILED\n", failures) + os.Exit(1) + } +} + +// requireSecondFactor logs in and asserts the account is correctly +// paused on *auth.ErrSecondFactorRequired, returning the pending +// token and enrolled methods. Returns "" and nil on failure rather +// than panicking, so one bad assertion doesn't crash the rest of the +// smoke test. +func requireSecondFactor(ctx context.Context, engine *cryden.Engine, step string) (string, []string) { + _, err := cryden.Login(ctx, engine, email, password, "1.2.3.4", "smoketest-agent") + var secondFactor *auth.ErrSecondFactorRequired + if !errors.As(err, &secondFactor) { + fail(fmt.Sprintf("%s: expected *auth.ErrSecondFactorRequired, got %v", step, err)) + return "", nil + } + pass(step) + return secondFactor.PendingToken, secondFactor.Methods +} + +func check(step string, err error) { + if err != nil { + fail(fmt.Sprintf("%s: unexpected error: %v", step, err)) + return + } + pass(step) +} + +func checkExpectError(step string, err error) { + if err == nil { + fail(fmt.Sprintf("%s: expected an error, got nil", step)) + return + } + pass(fmt.Sprintf("%s (%v)", step, err)) +} + +func pass(step string) { + fmt.Println("✓", step) +} + +func fail(msg string) { + failures++ + fmt.Println("✗", msg) +} + +func extractSecretFromURL(otpauthURL string) (string, error) { + u, err := url.Parse(otpauthURL) + if err != nil { + return "", err + } + secret := u.Query().Get("secret") + if secret == "" { + return "", fmt.Errorf("no secret query param found in %q", otpauthURL) + } + return secret, nil +} From 126eb0d3667948705e4a9937385e3215b45530b0 Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Tue, 1 Sep 2026 18:36:41 +0000 Subject: [PATCH 23/25] fix: update login_second_factor_test.go for the recoveryCodeStore param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Missed in the earlier signature-change commit — this file's Login() call sites still had the pre-recovery-codes arity and would have failed to compile. --- auth/login_second_factor_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/auth/login_second_factor_test.go b/auth/login_second_factor_test.go index 7bdaa9e..31900a6 100644 --- a/auth/login_second_factor_test.go +++ b/auth/login_second_factor_test.go @@ -31,7 +31,7 @@ func TestLogin_WebAuthnOnlyReportsWebAuthnMethod(t *testing.T) { users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) registerRealPasskeyForUser(t, ctx, users, webauthnStore, provider, enc, ids, audit, "user-1") - _, err := Login(ctx, users, sessions, nil, webauthnStore, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, + _, err := Login(ctx, users, sessions, nil, webauthnStore, nil, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4", "test-agent", 5, time.Minute) var secondFactor *ErrSecondFactorRequired @@ -66,7 +66,7 @@ func TestLogin_TOTPAndWebAuthnBothReportBothMethods(t *testing.T) { enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") registerRealPasskeyForUser(t, ctx, users, webauthnStore, provider, enc, ids, audit, "user-1") - _, err := Login(ctx, users, sessions, totpStore, webauthnStore, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, + _, err := Login(ctx, users, sessions, totpStore, webauthnStore, nil, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4", "test-agent", 5, time.Minute) var secondFactor *ErrSecondFactorRequired @@ -110,7 +110,7 @@ func TestLogin_NoSecondFactorEnrolledIssuesTokensDirectly(t *testing.T) { hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) - tokens, err := Login(ctx, users, sessions, totpStore, webauthnStore, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, + tokens, err := Login(ctx, users, sessions, totpStore, webauthnStore, nil, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4", "test-agent", 5, time.Minute) if err != nil { t.Fatalf("unexpected error: %v", err) From 7df7b9dd5cbdb371d0628994835f4366cede93c1 Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Tue, 1 Sep 2026 18:50:26 +0000 Subject: [PATCH 24/25] fix: generate recovery codes via crypto/rand directly, not TokenGenerator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NewCryptoRandTokenGenerator enforces a 128-bit minimum meant for session/refresh tokens and rejected the 5-byte length used here outright — GenerateRecoveryCodes failed unconditionally with ErrTokenByteLengthTooShort before ever producing a code. Recovery codes are generated directly via crypto/rand instead, using 8 bytes (64 bits) per code — short and human-typeable by design, single-use, and rate-limited the same as any other login attempt, so the 128-bit session-token bar was never the right minimum for this value in the first place. Codes are now formatted as four dash- separated 4-character hex groups (e.g. a1b2-c3d4-e5f6-a7b8); hashing strips the dashes (and case/whitespace) first, so formatting is purely cosmetic. --- auth/recoverycodes.go | 47 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/auth/recoverycodes.go b/auth/recoverycodes.go index 3925d8d..44718ca 100644 --- a/auth/recoverycodes.go +++ b/auth/recoverycodes.go @@ -2,6 +2,8 @@ package auth import ( "context" + "crypto/rand" + "encoding/hex" "errors" "strings" @@ -17,6 +19,16 @@ import ( // systems ship 8-10), not something worth exposing as a knob. const recoveryCodeCount = 10 +// recoveryCodeByteLength is 8 bytes (64 bits) per code — generated +// directly via crypto/rand rather than through TokenGenerator, which +// enforces a 128-bit minimum meant for session/refresh tokens and +// rejects anything shorter. That minimum doesn't apply here: a +// recovery code is short and human-typeable by design, single-use, +// and each attempt already goes through the same rate limiting as any +// other login attempt — 64 bits is the right tradeoff for this +// specific use case, not a relaxation of the session-token bar. +const recoveryCodeByteLength = 8 + var ( // ErrNoSecondFactorEnrolled is returned by GenerateRecoveryCodes // if the account has no confirmed TOTP secret and no registered @@ -68,16 +80,11 @@ func GenerateRecoveryCodes( rawCodes := make([]string, recoveryCodeCount) toStore := make([]store.RecoveryCode, recoveryCodeCount) - gen, err := token.NewCryptoRandTokenGenerator(5) - if err != nil { - return nil, err - } for i := range rawCodes { - raw, err := gen.New() + formatted, err := generateRecoveryCode() if err != nil { return nil, err } - formatted := raw[:5] + "-" + raw[5:] rawCodes[i] = formatted toStore[i] = store.RecoveryCode{CodeHash: hashRecoveryCode(formatted)} } @@ -150,10 +157,32 @@ func CompleteLoginWithRecoveryCode( } // hashRecoveryCode normalizes user input (case, surrounding -// whitespace) before hashing, since people will retype these by hand -// and the formatting ("ABCDE-FGHIJ") is just for readability, not -// part of the actual secret value. +// whitespace, and the dash separators) before hashing, since people +// will retype these by hand and the formatting ("a1b2-c3d4-e5f6-a7b8") +// is just for readability, not part of the actual secret value. func hashRecoveryCode(raw string) string { normalized := strings.ToLower(strings.TrimSpace(raw)) + normalized = strings.ReplaceAll(normalized, "-", "") return token.HashToken(normalized) } + +// generateRecoveryCode produces one recoveryCodeByteLength-byte random +// value via crypto/rand, hex-encoded and grouped into dash-separated +// 4-character blocks for readability (e.g. "a1b2-c3d4-e5f6-a7b8") — +// purely cosmetic, stripped again by hashRecoveryCode before hashing. +func generateRecoveryCode() (string, error) { + buf := make([]byte, recoveryCodeByteLength) + if _, err := rand.Read(buf); err != nil { + return "", err + } + hexStr := hex.EncodeToString(buf) + var groups []string + for i := 0; i < len(hexStr); i += 4 { + end := i + 4 + if end > len(hexStr) { + end = len(hexStr) + } + groups = append(groups, hexStr[i:end]) + } + return strings.Join(groups, "-"), nil +} From a385c1aa7c338c397d14e14d8de9069efe82b81d Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Thu, 3 Sep 2026 11:22:49 +0100 Subject: [PATCH 25/25] fix/oauth-second-factor-and-recovery-codes --- go.mod | 13 ++++++++++++- go.sum | 40 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ccd1b6f..55a84b2 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,18 @@ require ( github.com/google/uuid v1.6.0 github.com/lib/pq v1.12.3 github.com/pquerna/otp v1.5.0 - golang.org/x/crypto v0.54.0 + golang.org/x/crypto v0.55.0 +) + +require ( + github.com/fxamacker/cbor/v2 v2.9.3 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-webauthn/x v0.3.0 // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/tinylib/msgp v1.6.4 // indirect + github.com/x448/float16 v0.8.4 // indirect + golang.org/x/sys v0.47.0 // indirect ) // github.com/pquerna/otp pulls in boombuler/barcode transitively (used diff --git a/go.sum b/go.sum index dd355d6..0224791 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,44 @@ +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/descope/virtualwebauthn v1.0.5 h1:fMXji5UMepJC51Ge6d4v5IAjiJQRKmXE9hlo/B9SczQ= +github.com/descope/virtualwebauthn v1.0.5/go.mod h1:lLCfN+DpCM3iisM4bCILZlFEWkC1Zo7ZgsxC45CUapI= +github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q= +github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-webauthn/webauthn v0.18.0 h1:PC8R3PNLEmjZf++WwcQlo1Z39S9rf8ma69rlwkypZhA= +github.com/go-webauthn/webauthn v0.18.0/go.mod h1:ymzZQhx3D/PrDjznemBdQJ23gHTaSDxUchM7sH1lUCg= +github.com/go-webauthn/x v0.3.0 h1:Q2X9vbrlP0Ed+QGEzixh1hthGZlDnzVT0XH/9IIQ0kE= +github.com/go-webauthn/x v0.3.0/go.mod h1:5OkdSQdOy7taRXWqvNHggtaPffmW94ybu3rZEER4I+I= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=