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 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} } 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) +} 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) + } +} diff --git a/cmd/smoketest/magic-link/main.go b/cmd/smoketest/magic-link/main.go new file mode 100644 index 0000000..d5b67e0 --- /dev/null +++ b/cmd/smoketest/magic-link/main.go @@ -0,0 +1,176 @@ +// 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, + TOTP: memory.NewTOTPStore(), + 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. 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 +} 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/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." 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") ) 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= 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 +} 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