emailchallenge

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Oct 14, 2026 License: Apache-2.0 Imports: 10 Imported by: 0

README

github.com/ubgo/auth/plugin/emailchallenge

Passwordless sign-in by one challenge that a link and a code both complete, and that always signs in the browser that asked, never the one that opened the mail: the Go port of better-auth-email-challenge. The guide is docs/plugins/emailchallenge.md.

Install

The package is in the core module github.com/ubgo/auth, so it brings no dependency beyond github.com/ubgo/sqlb.

How it works

Step Route What happens
The browser asks POST /auth/emailchallenge/start {identifier} A challenge is stored, the poll token goes to the browser (cookie __Host-auth_email_challenge, and pollToken in the answer), the link and code go to the address
The asking browser waits POST /auth/emailchallenge/poll pending, then completed with a session once approved, or expired
Or types the code POST /auth/emailchallenge/verify-otp {otp} Signs in at once; three wrong codes spend the challenge
The link is opened GET /auth/emailchallenge/context?token= Changes nothing on another device (needs-confirmation); approves at once in the asking browser
The person confirms POST /auth/emailchallenge/approve {token} Approves; signs nobody in

An address with no account gets the same answer and a challenge that is never mailed and polls pending until it expires. Every secret is stored as a hash; the poll spends an approved challenge with a conditional delete, so one approval signs in once.

Options

SignUp(), Kinds(...), WithTTL(d) (5 minutes), WithMaxAttempts(n) (3), WithLinkMode(CrossDevice|SameDevice), Links(fn).

Tests

Unit tests in this package; the flows on SQLite, Postgres and MySQL, and every database failure, in integration/p3_community_test.go.

Documentation

Overview

Package emailchallenge signs users in without a password by one challenge that a link and a code both complete, and that always signs in the browser that asked, never the one that opened the mail: better-auth-email-challenge.

A magic link signs in whichever browser opens it, which is the wrong one when the mail is read on a phone and the sign-in started on a laptop. Here the browser that starts a challenge keeps a poll token (a cookie, and in the answer for a client without cookies), the mail carries an approval link and a code, and:

  • opening the link on another device only shows what is being approved (context, a GET that changes nothing), and approving is a POST, so a mail scanner that fetches every link approves nothing;
  • opening it in the browser that holds the poll token approves at once, because only that browser can hold it;
  • the approval never creates a session: the poll does, for the holder of the poll token, and the conditional delete that spends the challenge lets exactly one poll through;
  • the code is typed in the asking browser and checked against that challenge only, through the core's attempt-counted codes.

Every secret is stored as a hash. Like magiclink, it knows no mail: it asks the core to deliver to whichever plugin owns the identifier's kind.

Index

Constants

View Source
const (
	EndpointStart     auth.EndpointID = "emailchallenge.start"
	EndpointPoll      auth.EndpointID = "emailchallenge.poll"
	EndpointVerifyOTP auth.EndpointID = "emailchallenge.verify-otp"
	EndpointContext   auth.EndpointID = "emailchallenge.context"
	EndpointApprove   auth.EndpointID = "emailchallenge.approve"
)

Operation ids, stable wire names a hook or a rate rule can name.

View Source
const (
	PurposeSignIn auth.Purpose = "emailchallenge.sign_in"
	PurposeOTP    auth.Purpose = "emailchallenge.otp"
)

Message and code purposes. PurposeSignIn is the message the delivering plugin sends (its link and code); PurposeOTP is the core code checked against one challenge id.

View Source
const (
	// DefaultTTL is how long a challenge lives: five minutes, as a magic
	// link's, so an intercepted mail is useful for minutes only.
	// ds:def id=defaultttl-55ds4pcc owner=@khanakia stability=stable
	DefaultTTL = 5 * time.Minute
	// DefaultMaxAttempts wrong codes spend a challenge's code.
	DefaultMaxAttempts = 3
	// DefaultPage is the app page the link opens: it calls context with the
	// token and, when asked to confirm, POSTs approve.
	DefaultPage = "/email-challenge"
)

Defaults, better-auth-email-challenge's own.

View Source
const (
	TableNameChallenge  = "auth_email_challenge"
	TableAliasChallenge = "ech"

	ColID           = "id"
	ColIdentifier   = "identifier"
	ColApprovalHash = "approval_hash"
	ColBindingHash  = "binding_hash"
	ColStatus       = "status"
	ColName         = "name"
	ColScope        = "scope"
	ColIP           = "ip"
	ColUserAgent    = "user_agent"
	ColExpiresAt    = "expires_at"
	ColApprovedAt   = "approved_at"
	ColCreatedAt    = "created_at"
)

The table: one row per open challenge, deleted when completed, swept when expired. No foreign key (ADR-0002) and no user id: the challenge is about an identifier, whose account may not exist yet.

View Source
const CookieName = "email_challenge"

CookieName is the plugin cookie that holds the poll token in the browser that started the challenge (authhttp names it __Host-auth_email_challenge).

View Source
const PluginName = "emailchallenge"

PluginName is the plugin's permanent identity and route prefix.

Variables

View Source
var (
	TableChallenge = sqlb.Table{Name: TableNameChallenge, Alias: TableAliasChallenge}

	ChallengeID         = sqlb.Col[string](TableChallenge, ColID)
	ChallengeIdentifier = sqlb.Col[string](TableChallenge, ColIdentifier)
	ChallengeApproval   = sqlb.Col[[]byte](TableChallenge, ColApprovalHash)
	ChallengeBinding    = sqlb.Col[[]byte](TableChallenge, ColBindingHash)
	ChallengeStatus     = sqlb.Col[Status](TableChallenge, ColStatus)
	ChallengeName       = sqlb.Col[string](TableChallenge, ColName)
	ChallengeScope      = sqlb.Col[string](TableChallenge, ColScope)
	ChallengeIP         = sqlb.Col[string](TableChallenge, ColIP)
	ChallengeUserAgent  = sqlb.Col[string](TableChallenge, ColUserAgent)
	ChallengeExpiresAt  = sqlb.Col[time.Time](TableChallenge, ColExpiresAt)
	ChallengeApprovedAt = sqlb.Col[*time.Time](TableChallenge, ColApprovedAt)
	ChallengeCreatedAt  = sqlb.Col[time.Time](TableChallenge, ColCreatedAt)
)

TableChallenge and its typed columns, for an app that reads them.

View Source
var (
	ErrInvalid      = auth.NewError("emailchallenge.invalid", http.StatusBadRequest, "This sign-in request is not valid or has expired.")
	ErrWrongBrowser = auth.NewError("emailchallenge.wrong_browser", http.StatusForbidden, "Open this link in the browser where you started signing in.")

	ErrNoKinds    = errors.New("emailchallenge: no identifier kind can receive a challenge; add a Verifiable sign-in kind whose plugin delivers, such as email")
	ErrBadKinds   = errors.New("emailchallenge: a kind given to Kinds must be registered, sign-in capable, verifiable and owned by a plugin that delivers")
	ErrNoBase     = errors.New("emailchallenge: links need auth.WithBaseURL or emailchallenge.Links")
	ErrBadTTL     = errors.New("emailchallenge: WithTTL needs a positive duration")
	ErrBadAttempt = errors.New("emailchallenge: WithMaxAttempts needs a positive count")
	ErrBadMode    = errors.New("emailchallenge: LinkMode must be CrossDevice or SameDevice")
)

Errors. ErrInvalid covers an unknown, expired, spent and foreign challenge alike, so no answer says which.

Functions

func DefaultText

func DefaultText(link, code, userAgent, ip string) string

DefaultText is the English mail body when the app's mailer has no template for PurposeSignIn: the link, the code, and the request it approves.

Types

type ContextResult

type ContextResult struct {
	State      State      `json:"state"`
	Identifier string     `json:"identifier,omitempty"`
	IP         string     `json:"ipAddress,omitempty"`
	UserAgent  string     `json:"userAgent,omitempty"`
	ExpiresAt  *time.Time `json:"expiresAt,omitempty"`
}

ContextResult is what the approval page shows. Everything but State is left out when State is invalid; the identifier, IP and user agent are those of the request that started the challenge, for the person to recognise or refuse.

type LinkMode

type LinkMode string

LinkMode chooses what a click on another device may do.

const (
	CrossDevice LinkMode = "cross-device"
	SameDevice  LinkMode = "same-device"
)

The link modes. CrossDevice (the default) lets another device approve after an explicit POST; SameDevice refuses every click that does not come from the browser that asked, so only a same-browser click approves.

type Option

type Option func(*Plugin)

Option configures the plugin.

func Kinds

func Kinds(kinds ...auth.IdentifierKindName) Option

Kinds limits challenges to these identifier kinds; by default every verifiable sign-in kind whose plugin delivers.

func Links(fn func(t auth.Token) string) Option

Links builds the URL a mail carries from the approval token, for an app whose page is not DefaultPage. The page must not approve on GET: it calls context, and approves with a POST.

func SignUp

func SignUp() Option

SignUp lets a completed challenge create an account for an identifier nobody has verified. Without it a challenge to an unknown identifier is accepted and never delivered, so the answer is the same either way and the poll stays pending until it expires.

func WithLinkMode

func WithLinkMode(m LinkMode) Option

WithLinkMode chooses CrossDevice (the default) or SameDevice.

func WithMaxAttempts

func WithMaxAttempts(n int) Option

WithMaxAttempts replaces DefaultMaxAttempts, the wrong codes a challenge survives.

func WithTTL

func WithTTL(d time.Duration) Option

WithTTL replaces DefaultTTL.

type Plugin

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

Plugin is the email challenge sign-in method. Build it with New.

func New

func New(opts ...Option) *Plugin

New builds the plugin.

func (*Plugin) Approve

func (p *Plugin) Approve(ctx context.Context, token auth.Token) (Result, error)

Approve approves the challenge of an approval token; it signs nobody in.

func (*Plugin) Context

func (p *Plugin) Context(ctx context.Context, token auth.Token) (ContextResult, error)

Context reports what the approval page should show for token.

func (*Plugin) Init

func (p *Plugin) Init(a *auth.Auth) error

Init fills the default kinds and link and refuses a kind whose plugin cannot deliver, which Validate cannot see.

func (*Plugin) Name

func (p *Plugin) Name() string

Name returns PluginName.

func (*Plugin) Operations

func (p *Plugin) Operations() []auth.Operation

Operations lists the five operations; all are public, since the mail and the poll token are the proofs.

func (*Plugin) OptionErr

func (p *Plugin) OptionErr() error

OptionErr is every error the options recorded, such as auth.ErrNilOption naming an option given nil; auth.New refuses to start while it is not nil (auth.OptionChecker).

func (*Plugin) Poll

func (p *Plugin) Poll(ctx context.Context, pollToken auth.Token) (Result, error)

Poll reports the challenge of pollToken and, once approved, signs its holder in.

func (*Plugin) RateLimits

func (p *Plugin) RateLimits() []auth.RateRule

RateLimits bounds mails per identifier and IP, guesses per IP, and polls per IP.

func (*Plugin) SignsIn

func (p *Plugin) SignsIn() bool

SignsIn reports that a challenge is a way in.

func (*Plugin) Start

func (p *Plugin) Start(ctx context.Context, identifier, name string) (Result, error)

Start opens a challenge for identifier and delivers its link and code when the identifier may receive one; the answer is the same when it may not.

func (*Plugin) Sweep

func (p *Plugin) Sweep(ctx context.Context, cutoff time.Time) (int64, error)

Sweep deletes challenges that expired before cutoff; a poll deletes the ones it sees expire, this catches the pages that were closed.

func (*Plugin) Tables

func (p *Plugin) Tables() []sqlb.Table

Tables declares the challenge table.

func (*Plugin) TypedMigrations

func (p *Plugin) TypedMigrations(engine string, t auth.SQLTypes) ([]auth.Migration, error)

TypedMigrations creates the challenge table. Both token hashes are unique: a lookup by either finds one row.

func (*Plugin) Validate

func (p *Plugin) Validate(c *auth.Config) error

Validate refuses a configuration that could never complete a challenge.

func (*Plugin) VerifyOTP

func (p *Plugin) VerifyOTP(ctx context.Context, pollToken auth.Token, otp string) (Result, error)

VerifyOTP signs the holder of pollToken in with the code from the mail.

type Result

type Result struct {
	State                State          `json:"state,omitempty"`
	PollToken            auth.WireToken `json:"pollToken,omitempty"`
	ExpiresAt            *time.Time     `json:"expiresAt,omitempty"`
	User                 *auth.UserView `json:"user,omitempty"`
	Created              bool           `json:"created,omitempty"`
	SecondFactorRequired bool           `json:"secondFactorRequired,omitempty"`
	Token                auth.Token     `json:"-"`
}

Result is the answer of start, poll, verify-otp and approve. Start fills PollToken and ExpiresAt; poll fills State and, once completed, the user; approve fills State. Token is the new session's, for Go callers and bearer transports, never encoded.

type StartOrToken

type StartOrToken struct {
	// Identifier is where the challenge goes, as typed; its kind is detected.
	Identifier string `json:"identifier,omitempty"`
	// Name is the display name of the account a completed challenge creates
	// (SignUp); ignored when the identifier has an owner.
	Name string `json:"name,omitempty"`
	// PollToken is the asking browser's secret, for a client that keeps no
	// cookies; a browser sends the cookie instead.
	PollToken auth.WireToken `json:"pollToken,omitempty"`
	// OTP is the code from the mail.
	OTP string `json:"otp,omitempty"`
	// Token is the approval token from the link.
	Token auth.WireToken `json:"token,omitempty"`
}

StartOrToken is the input of start, poll, verify-otp and approve. Each reads its own fields: start Identifier and Name; poll PollToken; verify-otp PollToken and OTP; approve Token.

func (StartOrToken) RateIdentifier

func (r StartOrToken) RateIdentifier() string

RateIdentifier counts starts per identifier, folded so case changes do not reset the limit.

type State

type State string

State is what context and poll report; it adds the states a row cannot have (invalid, wrong-browser, completed).

const (
	StateInvalid           State = "invalid"
	StateNeedsConfirmation State = "needs-confirmation"
	StateApproved          State = "approved"
	StateWrongBrowser      State = "wrong-browser"
	StatePending           State = "pending"
	StateCompleted         State = "completed"
	StateExpired           State = "expired"
)

The reported states, better-auth-email-challenge's names.

type Status

type Status string

Status is a challenge's state in the table. A completed challenge is deleted, so there is no consumed state.

const (
	StatusPending  Status = "pending"
	StatusApproved Status = "approved"
)

The stored states.

type TokenRequest

type TokenRequest struct {
	Token     auth.WireToken `json:"token"`
	PollToken auth.WireToken `json:"pollToken,omitempty"`
}

TokenRequest is the query of context: the approval token from the link, and a poll token for a client without cookies.

Jump to

Keyboard shortcuts

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