emaillogin

package
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 19, 2026 License: MIT Imports: 8 Imported by: 0

Documentation

Overview

Package emaillogin implements the two passwordless email flows: a magic link, and a short sign-in code the user types.

They are the same flow with different entropy

Both are request-deliver-redeem, and both prove one thing: that whoever completes them can read mail sent to that address. The difference is the credential. A magic link carries 256 bits and is redeemed by presenting it, so guessing is not a threat. A six-digit code carries about twenty, and is therefore only as safe as the limit on how many times it can be guessed — which is why Config.MaxCodeAttempts exists, why a wrong code is counted even though it did not match, and why requesting a new code destroys the old one rather than adding to a pool of valid answers.

Accounts are created on redemption, never on request

A passwordless sign-in can create an account, and it does so only once the token comes back. Creating one when the link is requested would let anybody fill a user table with addresses they do not control, simply by typing them into a form. Delivering to an inbox and having the token return is what proves control, and until that happens nothing is stored against the address but a pending token.

What it does not do

It issues no authit credential. Like pat, device, oidc and passkey, it answers "who is this" and leaves what to hand back to the host.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrInvalidToken means the magic link or code did not resolve: wrong,
	// expired, already used, or destroyed by too many failed guesses.
	//
	// It is deliberately one error for all of those. Distinguishing
	// "expired" from "wrong" tells an attacker whether the code they tried
	// was ever real, and distinguishing "no such token" from "no such
	// account" tells them which addresses are registered.
	ErrInvalidToken = errors.New("authit/emaillogin: invalid or expired sign-in credential")
	// ErrSignUpDisabled means the address matches no account and
	// Config.DisableSignUp is set. It is returned at redemption, so it
	// never reveals at request time whether an address is registered.
	ErrSignUpDisabled = errors.New("authit/emaillogin: sign-up by email is disabled")
)
View Source
var ErrRateLimited = ratelimit.ErrRateLimited

ErrRateLimited is returned when Config.RateLimiter refuses. It is an alias for ratelimit.ErrRateLimited, not a distinct error, so a caller holding either matches both.

It is a refusal, not a fault: the caller did nothing wrong except arrive too often, and the answer is to wait rather than to change the request. ratelimit.RetryAfter reports how long, when the limiter said.

Functions

This section is empty.

Types

type Config

type Config struct {
	// LinkTTL is how long a magic link stays valid. Defaults to 15
	// minutes. It is short because the link is a bearer credential sitting
	// in an inbox, and inboxes are forwarded, synced and breached.
	LinkTTL time.Duration
	// CodeTTL defaults to 10 minutes, shorter still: the shorter a
	// low-entropy credential is live, the less an attacker's guessing
	// budget is worth.
	CodeTTL time.Duration
	// CodeLength defaults to 6 digits. Raising it buys entropy that
	// MaxCodeAttempts mostly makes unnecessary, at the cost of a code
	// people mistype.
	CodeLength int
	// MaxCodeAttempts is how many wrong guesses destroy a code. Defaults
	// to 5.
	//
	// This is the control the whole flow rests on. Six digits is a million
	// possibilities; five guesses per code makes a hit a one-in-two-hundred-
	// thousand event, and requesting a fresh code costs an email and starts
	// again from zero. Without it the code is guessable in an afternoon.
	MaxCodeAttempts int
	// DisableSignUp refuses to create an account for an address that
	// matches no existing user. The default is to create one, which is
	// safe here because the account is created on redemption -- proof of
	// inbox control -- and not on request.
	DisableSignUp bool
	// RateLimiter throttles issuing either credential, and redeeming a
	// code. Nil means ratelimit.Noop. Keys:
	//
	//	email-login:request:<email>  a new link or code for an address
	//	email-login:redeem:<email>   a code redemption attempt
	//
	// Redeeming a magic link is deliberately not metered. There is nothing
	// to meter it on: the address is not known until the token resolves,
	// so there is no key before the lookup -- and a 256-bit token does not
	// need one, where six digits do.
	//
	// The request key bounds using this flow to flood somebody's inbox.
	// The redeem key is a second line behind MaxCodeAttempts, bounding an
	// attacker who burns codes and requests fresh ones.
	RateLimiter ratelimit.Limiter
	// AuditLogger receives request and redemption events.
	AuditLogger audit.Logger
}

Config tunes the flows.

type NoopSender

type NoopSender struct{}

NoopSender delivers nothing. It is the default, and is useful for tests and for hosts that deliver out of band.

func (NoopSender) SendMagicLink(context.Context, string, string) error

func (NoopSender) SendSignInCode

func (NoopSender) SendSignInCode(context.Context, string, string) error

type Result

type Result struct {
	// User is the account this sign-in resolved to.
	User store.User
	// CreatedUser reports whether redeeming created the account, so a host
	// can run its own onboarding.
	CreatedUser bool
}

Result is a completed passwordless sign-in.

type Sender

type Sender interface {
	SendMagicLink(ctx context.Context, email, token string) error
	SendSignInCode(ctx context.Context, email, code string) error
}

Sender delivers the credential. It is separate from user.EmailSender so that adding these flows does not change an interface every existing host already implements.

The address is normalised. For SendMagicLink the token is the raw value to embed in a URL; for SendSignInCode it is the digits to show.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service implements the passwordless email flows.

func NewService

func NewService(stores Stores, sender Sender, cfg Config) (*Service, error)

NewService constructs a Service. sender may be nil, in which case NoopSender is used.

func (s *Service) RedeemMagicLink(ctx context.Context, rawToken string) (Result, error)

RedeemMagicLink consumes a link token and resolves it to a user, creating the account if the address is new and sign-up is allowed.

func (*Service) RedeemSignInCode

func (s *Service) RedeemSignInCode(ctx context.Context, email, code string) (Result, error)

RedeemSignInCode consumes a numeric code for an address.

A wrong code is counted against the token even though it did not match, and the token is destroyed once MaxCodeAttempts is reached. That counting is what makes six digits survivable: without it the code is guessable in an afternoon.

func (s *Service) RequestMagicLink(ctx context.Context, email string) error

RequestMagicLink emails a single-use sign-in link to the address.

It always reports success, whether or not the address is registered. Passwordless sign-in is a form where anybody can type any address, so a response that differed would be a membership oracle for the whole user table — and the fix costs nothing, because the person who owns the inbox finds out either way and nobody else does.

func (*Service) RequestSignInCode

func (s *Service) RequestSignInCode(ctx context.Context, email string) error

RequestSignInCode emails a short numeric code to the address. Same no-enumeration behaviour as RequestMagicLink.

type Stores

type Stores struct {
	Users  store.UserStore
	Tokens store.EmailLoginStore
	// Tx is optional; see store.TxRunner. With it, creating an account and
	// consuming the token that authorised it are atomic.
	Tx store.TxRunner
}

Stores are the ports this package needs.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL