Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
702094c
fix: return real error instead of swallowing crypto/rand failure
raymondproguy Aug 30, 2026
da264a3
test: add regression coverage for the swallowed rand.Read error
raymondproguy Aug 30, 2026
7eca8c7
fix: close login timing side-channel for nonexistent-email attempts
raymondproguy Aug 30, 2026
e9a5aeb
test: add timing regression test for the login enumeration fix
raymondproguy Aug 30, 2026
bfc9d89
feat: add Encryptor interface and AES-256-GCM implementation
raymondproguy Aug 30, 2026
08cdc12
test: add Encryptor unit tests
raymondproguy Aug 30, 2026
cb146f3
feat: add TOTPGenerator interface backed by pquerna/otp
raymondproguy Aug 30, 2026
87434fe
test: add TOTPGenerator unit tests
raymondproguy Aug 30, 2026
adf1f27
feat: add TOTPStore interface and TOTPSecret type
raymondproguy Aug 30, 2026
c182f7b
feat: add in-memory TOTPStore implementation
raymondproguy Aug 30, 2026
30435ac
feat: add Postgres TOTPStore implementation
raymondproguy Aug 30, 2026
7ce93d3
feat: add totp_secrets table migration
raymondproguy Aug 30, 2026
83ba8db
feat: add MFAPendingIssuer for second-factor login handoff
raymondproguy Aug 30, 2026
f909e86
feat: add TOTP enrollment, confirmation, and disable flows
raymondproguy Aug 30, 2026
2135756
test: add unit tests for TOTP enrollment/confirm/disable
raymondproguy Aug 30, 2026
2584a2a
feat: pause Login with ErrTOTPRequired for accounts with 2FA enabled
raymondproguy Aug 30, 2026
c3354e2
test: add Login/CompleteLoginWithTOTP integration tests
raymondproguy Aug 30, 2026
3c2c801
feat: wire TOTP into Config, Engine, and the public facade
raymondproguy Aug 30, 2026
4fa2374
docs: document TOTP (2FA) setup and usage in README
raymondproguy Aug 30, 2026
295cb71
docs: add manual testing guide for 2FA/TOTP
raymondproguy Aug 30, 2026
2cb21f2
feat: add in-memory smoke test for 2FA/TOTP
raymondproguy Aug 30, 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
42 changes: 41 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,45 @@ err := cryden.LinkOAuthIdentity(ctx, engine, userID, "google", externalID, email

`userID` must come from an already-verified session — never trust an email alone to authorize a link. Calling either function without `Config.OAuth` set returns `cryden.ErrOAuthNotConfigured`.

## Two-factor authentication (TOTP)

Requires two additional `Config` fields:

```go
engine, err := cryden.New(cryden.Config{
// ...required fields...
TOTP: postgres.NewTOTPStore(db), // or memory.NewTOTPStore()
EncryptionKey: os.Getenv("ENCRYPTION_KEY"), // separate secret from JWTSecret
TOTPIssuerName: "YourApp", // shown in the user's authenticator app
})
```

`EncryptionKey` is required whenever `TOTP` is set — a TOTP secret has to be recoverable in plaintext to validate codes against it, so (unlike passwords and tokens) it's encrypted rather than hashed. Use a different value from `JWTSecret`, not the same one twice.

Enrollment is a two-step confirm flow — a secret never gates login until the user proves they've actually captured it:

```go
otpauthURL, err := cryden.EnrollTOTP(ctx, engine, userID)
// render otpauthURL as a QR code for the user to scan

err = cryden.ConfirmTOTP(ctx, engine, userID, codeFromApp)
// only after this succeeds does the account require a code to log in
```

Once confirmed, `Login` no longer issues tokens directly for that account — it returns `*auth.ErrTOTPRequired` (retrievable via `errors.As`) carrying a short-lived pending token:

```go
tokens, err := cryden.Login(ctx, engine, email, password, callerIP, userAgent)

var totpRequired *auth.ErrTOTPRequired
if errors.As(err, &totpRequired) {
// prompt for a code, then:
tokens, err = cryden.CompleteLoginWithTOTP(ctx, engine, totpRequired.PendingToken, code, callerIP, userAgent)
}
```

The pending token expires after 5 minutes and is only ever valid for completing that one login — it's a distinct token type from an access token, not just a permissive one. `DisableTOTP(ctx, engine, userID, currentPassword)` removes 2FA from an account and requires the current password as re-confirmation. Calling any TOTP function without `Config.TOTP` set returns `cryden.ErrTOTPNotConfigured`.

## 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 @@ -146,6 +185,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) with encrypted-at-rest secrets and a confirm-before-enforce enrollment flow — see [Two-factor authentication](#two-factor-authentication-totp)
- 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 @@ -160,7 +200,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. MFA, 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. Magic links, SMS OTP, WebAuthn, SAML, and other advanced auth methods are planned for later releases.

## License

Expand Down
35 changes: 35 additions & 0 deletions auth/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,38 @@ func (e *ErrOAuthEmailConflict) Error() string {
// link to a new account — that would let one user hijack a provider
// identity another user already claimed.
var ErrOAuthIdentityAlreadyLinked = errors.New("auth: this provider account is already linked to a different user")

// ErrTOTPRequired is returned by Login when the account has a
// confirmed TOTP secret — a correct password is no longer sufficient
// on its own. PendingToken must be presented to CompleteLoginWithTOTP
// together with the user's current code. It is NOT a valid access or
// refresh token and proves nothing beyond "this caller already
// supplied a correct password for this user." Deliberately a struct
// type (not a plain sentinel), same reasoning as
// ErrOAuthEmailConflict — callers use errors.As to retrieve it.
type ErrTOTPRequired struct {
PendingToken string
}

func (e *ErrTOTPRequired) Error() string {
return "auth: TOTP code required to complete login"
}

var (
// ErrTOTPNotEnabled is returned when a caller acts as though an
// account has TOTP enabled (e.g. CompleteLoginWithTOTP) but it
// doesn't, or its enrollment was never confirmed.
ErrTOTPNotEnabled = errors.New("auth: TOTP is not enabled for this account")
ErrTOTPAlreadyEnabled = errors.New("auth: TOTP is already enabled for this account")
// ErrInvalidTOTPCode covers both a wrong code and an expired
// pending-login token that failed at the code-check step —
// deliberately not differentiated further than that, same
// enumeration-avoidance reasoning as ErrInvalidCredentials.
ErrInvalidTOTPCode = errors.New("auth: invalid or expired TOTP code")
// ErrInvalidPendingLogin is returned by CompleteLoginWithTOTP when
// pendingToken itself fails verification (expired, tampered, or
// not a pending-login token at all) — distinct from
// ErrInvalidTOTPCode, which covers a wrong code against an
// otherwise-valid pending login.
ErrInvalidPendingLogin = errors.New("auth: login session expired or invalid, please log in again")
)
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, hasher, ids, refreshGen, jwtIssuer, limiter, audit, log,
_, err := Login(ctx, users, sessions, 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, hasher, ids, refreshGen, jwtIssuer, limiter, audit, log,
_, err := Login(ctx, users, sessions, 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, hasher, ids, refreshGen, jwtIssuer, limiter, audit, log,
Login(ctx, users, sessions, 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, hasher, ids, refreshGen, jwtIssuer, limiter, audit, log,
_, err := Login(ctx, users, sessions, 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, hasher, ids, refreshGen, jwtIssuer, limiter, audit, log,
Login(ctx, users, sessions, 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, hasher, ids, refreshGen, jwtIssuer, limiter, audit, log,
_, err := Login(ctx, users, sessions, 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, hasher, ids, refreshGen, jwtIssuer, limiter, audit, log,
_, err = Login(ctx, users, sessions, 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
64 changes: 61 additions & 3 deletions auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ import (
// token pair). callerIP and userAgent are required, caller-supplied —
// never inferred inside the engine.
//
// totpStore and pendingIssuer are optional (nil if Config.TOTP isn't
// set). If the account has a confirmed TOTP secret, Login does not
// issue tokens directly — it returns *ErrTOTPRequired carrying a
// short-lived pending token; the caller must then call
// CompleteLoginWithTOTP with that token plus a code.
//
// lockoutThreshold and lockoutDuration configure account lockout: after
// lockoutThreshold consecutive failed attempts, the account is locked
// (persistent, DB-backed — survives restarts, correct across multiple
Expand All @@ -23,10 +29,12 @@ func Login(
ctx context.Context,
users store.UserStore,
sessions store.SessionStore,
totpStore store.TOTPStore,
hasher security.Hasher,
ids security.IDGenerator,
refreshGen token.TokenGenerator,
jwtIssuer *token.JWTIssuer,
pendingIssuer *token.MFAPendingIssuer,
limiter security.RateLimiter,
audit store.AuditStore,
log logger.Logger,
Expand All @@ -49,6 +57,14 @@ func Login(

user, err := users.GetByEmail(ctx, email)
if err != nil {
// Still pay bcrypt's cost even though there's no hash to
// check against — hasher.Hash runs the same underlying cost
// function as hasher.Compare. Without this, a nonexistent-
// email response returns measurably faster than a wrong-
// password one, letting an attacker enumerate registered
// emails by timing alone even though the returned error is
// identical either way.
_, _ = hasher.Hash(password)
recordLoginFailure(ctx, audit, log, "", callerIP, "no_such_user")
return Tokens{}, ErrInvalidCredentials
}
Expand Down Expand Up @@ -87,6 +103,43 @@ func Login(
log.Error("login: reset failed-attempts error", map[string]string{"error": err.Error(), "user_id": user.ID})
}

// Password verified. If this account has a confirmed TOTP secret,
// pause here instead of issuing tokens — a correct password alone
// is no longer sufficient to complete login.
if totpStore != nil {
secretRec, err := totpStore.GetByUserID(ctx, user.ID)
if err == nil && secretRec.ConfirmedAt != nil {
pendingToken, issueErr := pendingIssuer.Issue(user.ID)
if issueErr != nil {
return Tokens{}, issueErr
}
log.Info("login: password verified, awaiting TOTP", map[string]string{"user_id": user.ID})
return Tokens{}, &ErrTOTPRequired{PendingToken: pendingToken}
}
}

return finishLogin(ctx, sessions, ids, refreshGen, jwtIssuer, audit, log, user, callerIP, userAgent, "")
}

// 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).
func finishLogin(
ctx context.Context,
sessions store.SessionStore,
ids security.IDGenerator,
refreshGen token.TokenGenerator,
jwtIssuer *token.JWTIssuer,
audit store.AuditStore,
log logger.Logger,
user store.User,
callerIP string,
userAgent string,
mfaMethod string,
) (Tokens, error) {
sessionID, err := ids.New()
if err != nil {
return Tokens{}, err
Expand Down Expand Up @@ -117,10 +170,15 @@ func Login(
return Tokens{}, err
}

var metadata map[string]string
if mfaMethod != "" {
metadata = map[string]string{"mfa": mfaMethod}
}
if err := audit.Record(ctx, store.AuditEvent{
Type: store.EventLoginSuccess,
UserID: user.ID,
IP: callerIP,
Type: store.EventLoginSuccess,
UserID: user.ID,
IP: callerIP,
Metadata: metadata,
}); err != nil {
log.Error("login: audit record failed", map[string]string{"error": err.Error(), "user_id": user.ID})
}
Expand Down
47 changes: 44 additions & 3 deletions auth/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ func TestLogin_Success(t *testing.T) {
hash, _ := hasher.Hash("correct-password")
users.Create(ctx, storeUser("user-1", "proguy@example.com", hash))

tokens, err := Login(ctx, users, sessions, hasher, ids, refreshGen, jwtIssuer, limiter, audit, log,
// 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, 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 @@ -49,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, hasher, ids, refreshGen, jwtIssuer, limiter, audit, log,
_, err := Login(ctx, users, sessions, 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 @@ -63,9 +65,48 @@ func TestLogin_NonexistentUserRejectedWithSameError(t *testing.T) {
log := testLogger{}
ctx := context.Background()

_, err := Login(ctx, users, sessions, hasher, ids, refreshGen, jwtIssuer, limiter, audit, log,
_, err := Login(ctx, users, sessions, 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)
}
}

func TestLogin_NonexistentUserTimingMatchesWrongPassword(t *testing.T) {
// Regression test for the timing side-channel: before the fix,
// the nonexistent-user path returned before ever calling
// hasher.Compare, making it measurably faster than a real
// wrong-password attempt and letting an attacker enumerate
// registered emails by response time alone even though the
// returned error was already identical. A real cost-4 bcrypt
// hash still takes single-digit milliseconds, so both paths
// should land in the same rough band, not orders of magnitude
// apart. This is a coarse smoke test, not a precise timing
// analysis — its job is to catch a future regression that removes
// the dummy hasher.Hash call entirely, not to certify
// constant-time behavior.
users, sessions, audit, hasher, ids, refreshGen, jwtIssuer, limiter := newLoginTestDeps(t)
log := testLogger{}
ctx := context.Background()

hash, _ := hasher.Hash("correct-password")
users.Create(ctx, storeUser("user-1", "proguy@example.com", hash))

start := time.Now()
Login(ctx, users, sessions, 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, 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)

// Nonexistent-user path should never be dramatically faster —
// allow a generous 2x margin either direction for test-runner
// noise, since this isn't a precision timing measurement.
ratio := float64(nonexistentUserDuration) / float64(wrongPasswordDuration)
if ratio < 0.5 {
t.Errorf("nonexistent-user login returned %v, wrong-password returned %v (ratio %.2f) — the dummy hash may not be running", nonexistentUserDuration, wrongPasswordDuration, ratio)
}
}
Loading
Loading