Skip to content

Feature/magic link - #7

Merged
raymondproguy merged 11 commits into
mainfrom
feature/magic-link
Sep 3, 2026
Merged

Feature/magic link#7
raymondproguy merged 11 commits into
mainfrom
feature/magic-link

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.
@raymondproguy
raymondproguy merged commit bc0e013 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