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
- Variables
- func DefaultText(link, code, userAgent, ip string) string
- type ContextResult
- type LinkMode
- type Option
- type Plugin
- func (p *Plugin) Approve(ctx context.Context, token auth.Token) (Result, error)
- func (p *Plugin) Context(ctx context.Context, token auth.Token) (ContextResult, error)
- func (p *Plugin) Init(a *auth.Auth) error
- func (p *Plugin) Name() string
- func (p *Plugin) Operations() []auth.Operation
- func (p *Plugin) OptionErr() error
- func (p *Plugin) Poll(ctx context.Context, pollToken auth.Token) (Result, error)
- func (p *Plugin) RateLimits() []auth.RateRule
- func (p *Plugin) SignsIn() bool
- func (p *Plugin) Start(ctx context.Context, identifier, name string) (Result, error)
- func (p *Plugin) Sweep(ctx context.Context, cutoff time.Time) (int64, error)
- func (p *Plugin) Tables() []sqlb.Table
- func (p *Plugin) TypedMigrations(engine string, t auth.SQLTypes) ([]auth.Migration, error)
- func (p *Plugin) Validate(c *auth.Config) error
- func (p *Plugin) VerifyOTP(ctx context.Context, pollToken auth.Token, otp string) (Result, error)
- type Result
- type StartOrToken
- type State
- type Status
- type TokenRequest
Constants ¶
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.
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.
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.
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.
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).
const PluginName = "emailchallenge"
PluginName is the plugin's permanent identity and route prefix.
Variables ¶
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.
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 ¶
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 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 ¶
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 ¶
WithLinkMode chooses CrossDevice (the default) or SameDevice.
func WithMaxAttempts ¶
WithMaxAttempts replaces DefaultMaxAttempts, the wrong codes a challenge survives.
type Plugin ¶
type Plugin struct {
// contains filtered or unexported fields
}
Plugin is the email challenge sign-in method. Build it with New.
func (*Plugin) Init ¶
Init fills the default kinds and link and refuses a kind whose plugin cannot deliver, which Validate cannot see.
func (*Plugin) Operations ¶
Operations lists the five operations; all are public, since the mail and the poll token are the proofs.
func (*Plugin) OptionErr ¶
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 ¶
Poll reports the challenge of pollToken and, once approved, signs its holder in.
func (*Plugin) RateLimits ¶
RateLimits bounds mails per identifier and IP, guesses per IP, and polls per IP.
func (*Plugin) Start ¶
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 ¶
Sweep deletes challenges that expired before cutoff; a poll deletes the ones it sees expire, this catches the pages that were closed.
func (*Plugin) TypedMigrations ¶
TypedMigrations creates the challenge table. Both token hashes are unique: a lookup by either finds one row.
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.