Skip to content

Fix/oauth second factor and recovery codes - #6

Merged
raymondproguy merged 25 commits into
mainfrom
fix/oauth-second-factor-and-recovery-codes
Sep 3, 2026
Merged

Fix/oauth second factor and recovery codes#6
raymondproguy merged 25 commits into
mainfrom
fix/oauth-second-factor-and-recovery-codes

Conversation

@raymondproguy

Copy link
Copy Markdown
Contributor

No description provided.

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.
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.
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.
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.
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.
- 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.
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.
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.
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.
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.
For tests and local experimentation only, matching the existing
in-memory store conventions (not a supported production backend).
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.
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.
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.
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.
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.
- Config.RecoveryCodes (optional, store.RecoveryCodeStore).
- New facade functions: GenerateRecoveryCodes,
  CompleteLoginWithRecoveryCode, each returning
  cryden.ErrRecoveryCodesNotConfigured if called without
  Config.RecoveryCodes set.
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.
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.
…ator

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.
@raymondproguy
raymondproguy merged commit a872399 into main Sep 3, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant