Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c9cc745
feat: add PurposeMagicLink, reusing the existing VerificationStore
raymondproguy Sep 1, 2026
a4b54f8
feat: add MagicLinkSender interface
raymondproguy Sep 1, 2026
0926387
refactor: extract completePrimaryAuth out of Login
raymondproguy Sep 1, 2026
8ce6b99
feat: add RequestMagicLink and CompleteMagicLink
raymondproguy Sep 1, 2026
a024ddc
test: add RequestMagicLink/CompleteMagicLink tests
raymondproguy Sep 1, 2026
11c0218
feat: wire magic-link login into Config, Engine, and the public facade
raymondproguy Sep 1, 2026
a36c6bc
docs: document magic-link login in README
raymondproguy Sep 1, 2026
162a998
docs: add manual testing guide for magic-link login
raymondproguy Sep 1, 2026
4438fbe
feat: add in-memory smoke test for magic-link login
raymondproguy Sep 1, 2026
8d0a2da
fix: configure TOTP in the magic-link smoke test's engine
raymondproguy Sep 1, 2026
9b68030
feat: add RecoveryCode type and RecoveryCodeStore interface
raymondproguy Sep 1, 2026
f64bf9b
feat: add in-memory RecoveryCodeStore implementation
raymondproguy Sep 1, 2026
1c9c822
feat: add Postgres RecoveryCodeStore implementation
raymondproguy Sep 1, 2026
3c0dc5d
feat: add recovery_codes table migration
raymondproguy Sep 1, 2026
3bac5c1
fix: route LoginWithOAuth through completePrimaryAuth
raymondproguy Sep 1, 2026
40c89f3
test: add regression tests for LoginWithOAuth's second-factor gate
raymondproguy Sep 1, 2026
433208d
feat: add recovery code generation and login completion
raymondproguy Sep 1, 2026
63acaad
test: add recovery code tests
raymondproguy Sep 1, 2026
fd466f8
feat: wire recovery codes into Config, Engine, and the public facade
raymondproguy Sep 1, 2026
0cfdfe6
docs: document recovery codes in README
raymondproguy Sep 1, 2026
0ee01a1
docs: add manual testing guide for recovery codes
raymondproguy Sep 1, 2026
a21679d
feat: add in-memory smoke test for recovery codes
raymondproguy Sep 1, 2026
126eb0d
fix: update login_second_factor_test.go for the recoveryCodeStore param
raymondproguy Sep 1, 2026
7df7b9d
fix: generate recovery codes via crypto/rand directly, not TokenGener…
raymondproguy Sep 1, 2026
a385c1a
fix/oauth-second-factor-and-recovery-codes
raymondproguy Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,58 @@ 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`.

## 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).
Expand All @@ -233,6 +285,8 @@ 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)
- 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)
Expand All @@ -247,7 +301,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

Expand Down
14 changes: 7 additions & 7 deletions auth/lockout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
85 changes: 73 additions & 12 deletions auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -107,40 +108,93 @@ 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, 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, 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. 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,
pendingIssuer *token.MFAPendingIssuer,
audit store.AuditStore,
log logger.Logger,
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 {
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}
}

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,
Expand All @@ -153,6 +207,7 @@ func finishLogin(
callerIP string,
userAgent string,
mfaMethod string,
extraMetadata map[string]string,
) (Tokens, error) {
sessionID, err := ids.New()
if err != nil {
Expand Down Expand Up @@ -185,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,
Expand Down
6 changes: 3 additions & 3 deletions auth/login_second_factor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 5 additions & 5 deletions auth/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
Loading
Loading