Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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)
Expand All @@ -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

Expand Down
37 changes: 33 additions & 4 deletions auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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}
}

Expand Down
156 changes: 156 additions & 0 deletions auth/magiclink.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading