magiclink

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

Adds passwordless sign-in by a single-use link sent to a user's verified email address (or any identifier kind whose plugin can deliver), and optionally sign-up by the same link.

Install

go get github.com/ubgo/auth

The package is part of the core module, so it adds no dependency. It sends nothing itself: it asks the core to deliver through whichever plugin owns the identifier's kind, such as plugin/email.

Wire it

From examples/magic-link:

a, err := auth.New(
	auth.WithDB(db, sqlb.SQLiteDialect{}),
	auth.WithBaseURL(baseURL),
	auth.WithPlugins(
		email.New(email.Login(), email.Recover(), email.WithMailer(m)),
		magiclink.New(magiclink.SignUp()),
	),
)

It needs at least one identifier kind that can sign in, has a verification flow, and whose plugin delivers messages. email.New(email.Login(), …) is one; a username is not. The plugin is a sign-in method by itself, so email plus magic links is a complete app.

The link opens a page of your app, DefaultPage (/magic-link) unless you pass Links. The page must POST the token to /magiclink/sign-in; a GET that signed in would be spent by every mail scanner that previews links. The example serves such a page. VerifyByGET() is the opt-in exception, better-auth's behaviour, with its risk below.

auth.New fails with:

Error Text Cause
ErrNoBase magiclink: links need auth.WithBaseURL or magiclink.Links neither set
ErrNoKinds magiclink: no identifier kind can receive a link; add a Verifiable kind whose plugin delivers, such as email no usable kind registered (checked in Validate, and again in Init for delivery)
ErrBadKinds magiclink: a kind given to Kinds must be registered, sign-in capable, verifiable and owned by a plugin that delivers a kind named in Kinds fails one of those

Options

Option What it does Default
Kinds(kinds...) Restrict links to these identifier kinds every sign-in, verifiable kind whose plugin delivers
SignUp() A link to an identifier nobody has verified creates the account on click off: such a link is not sent at all
WithTTL(d) Link lifetime DefaultTTL = 15 minutes
Links(fn) Builds the URL from the token baseURL + DefaultPage + "?token=…"
VerifyByGET() better-auth's GET /magic-link/verify: the link is baseURL + DefaultMountPrefix (/auth) + /magiclink/verify?token=… with the send request's callbackURL, newUserCallbackURL and errorCallbackURL; the GET signs in and redirects to newUserCallbackURL when it created the account, else callbackURL, and a bad link to errorCallbackURL (or callbackURL, or auth.WithErrorURL) with ?error=<code>; with no callbackURL it answers JSON. Each page must pass auth.Auth.TrustsReturnURL (magiclink.callback_invalid). Risk: a mail scanner that fetches the link signs in and spends it, and a GET sign-in cannot be origin-checked, so a link to an attacker's own account signs a victim into it (login CSRF); auth.WithFetchMetadata judges mutations only and does not cover this GET off: the link opens your page, which POSTs
GenerateToken(fn) better-auth's generateToken(email), a plain override: fn(ctx, identifier) returns the link's token, used as is; an error or an empty token from fn fails the send. Risk: a link token is found by its hash alone with no attempt counter, so a guessable token signs in whoever guesses it off: 256 random bits
WithSendLimit(n, window) Rate rule of send per identifier 3 per 15 minutes (sendLimit, sendWindow)
WithIPLimit(n, window) Rate rule of send per IP 20 per hour (sendIPLimit, sendIPWindow)

Routes and Go API

The routes are under your mount prefix (for example /auth).

Route Go method Requirement Rate limit
POST /magiclink/send (identifier, optional name for the account a link creates, optional metadata for the sender) Send(ctx, identifier), SendAs(ctx, identifier, name), SendWithMetadata(ctx, identifier, metadata), SendWith(ctx, SendRequest{…, CallbackURL, NewUserCallbackURL, ErrorCallbackURL}) Public 3 per 15 minutes per identifier (sendLimit, sendWindow); 20 per hour per IP (sendIPLimit, sendIPWindow)
GET /magiclink/verify?token=…&callbackURL=…&newUserCallbackURL=…&errorCallbackURL=… none: a browser follows the mailed link Public none; only with VerifyByGET
POST /magiclink/sign-in SignIn(ctx, token) returns SignInResult{User, Created, SecondFactorRequired, Token} Public none (the token is single-use)
  • send answers OK for every input. It sends only to the verified owner's identifier, or, with SignUp(), to an identifier nobody has verified. It never sends to an unverified row's account, a kind outside Kinds, or a value that is no identifier.
  • sign-in spends the token and starts a session. A user with two-factor gets a pending session (SecondFactorRequired) only under twofactor.WithPolicy(twofactor.AskEverySignIn): the two-factor plugin's default, like better-auth, does not ask after a magic link.
  • metadata (an invite id, a page to return to) travels with the message to the delivering plugin's sender — email.Mail.Metadata, or phone.MetadataSender — and is never stored. At most email.MaxMetadataEntries entries of email.MaxMetadataLength bytes; over the bounds the request is refused with auth.bad_request (TestMetadata_ReachesTheSender).

Tables and columns

The plugin owns no table and adds no column. A link is a core verification token (auth_verification) with purpose PurposeSignIn (magiclink.sign-in). Its identifier is <kind>:<normalized value> and its meta is the tenant it was sent in.

Errors

Error Code HTTP When
auth.ErrTokenInvalid auth.token_invalid 400 the token is bad, spent or expired; it was clicked in another tenant; or, without SignUp(), the identifier has no verified owner any more
magiclink.ErrCallbackInvalid magiclink.callback_invalid 400 send or the GET verify with a callbackURL, newUserCallbackURL or errorCallbackURL that is not a site path or a URL on a trusted origin; judged on the input alone, and nothing is sent or spent
auth.ErrRateLimited auth.rate_limited 429 a send limit above

ErrNoBase, ErrNoKinds and ErrBadKinds are configuration errors from auth.New.

Security notes

Property Pinned by
A link signs in only the verified owner, works once and expires; unknown and unverified identifiers get the same answer and no mail integration/TestMagicLink_SignsInTheVerifiedOwnerOnly
With SignUp(), the clicked identifier is created verified integration/TestMagicLink_SignUpCreatesAVerifiedAccount
A link is bound to its tenant and refused in another integration/TestMagicLink_BoundToItsTenant
A kind whose plugin cannot deliver is refused at start. Otherwise send would fail only for identifiers that have an account, which reveals them plugin/magiclink/TestInit_RefusesAKindThatCannotDeliver
Kinds, WithTTL, Links, and a link to an address removed since integration/TestIdentMagicLink_OptionsAndRefusals
Every database failure fails the operation integration/TestIdentFaults_MagicLink
GenerateToken's token is the one linked and it signs in; a generator error fails the send and mails nothing integration/TestR21_MagicLinkGenerateToken, TestR21_GeneratorErrorsFailTheSend

Limits:

  • Whoever reads the mailbox can sign in; the short DefaultTTL and single use bound that window.
  • Mail is delivered after the transaction commits, in the background. A delivery error is logged, not returned.

Example

Documentation

Overview

Package magiclink signs users in with a single-use link sent to one of their identifiers, usually an email address.

It knows no mail and no SMS: it asks the core to deliver to whichever plugin owns the identifier's kind, so it works with email, phone, or a kind a third plugin adds. A link signs in only the verified owner of the identifier; with SignUp it also creates an account for an identifier nobody has verified, whose control the click has just proven.

Index

Constants

View Source
const (
	EndpointSend   auth.EndpointID = "magiclink.send"
	EndpointSignIn auth.EndpointID = "magiclink.sign-in"
	// EndpointVerify is the GET sign-in VerifyByGET adds.
	EndpointVerify auth.EndpointID = "magiclink.verify"
)

Operation ids, exported so a hook or a rate rule can name them. They are wire names, stable across releases.

View Source
const (
	// DefaultTTL is short: a sign-in link is used within minutes or not at
	// all, and a long one is only useful to whoever reads the mailbox later.
	//ds:def id=defaultttl-z8p2fp62 owner=@khanakia stability=stable
	DefaultTTL = 15 * time.Minute
	// DefaultPage is the app page a link opens; it POSTs the token to
	// magiclink.sign-in. A GET that signed in would be spent by every mail
	// scanner that previews links.
	//ds:def id=defaultpage-uke973qb owner=@khanakia stability=stable
	DefaultPage = "/magic-link"
)

Defaults and limits.

View Source
const DefaultMountPrefix = "/auth"

DefaultMountPrefix is where VerifyByGET's default link assumes the auth handler is mounted, as the oauth plugin's default callback does.

View Source
const PluginName = "magiclink"

PluginName is the plugin's permanent identity.

View Source
const PurposeSignIn auth.Purpose = "magiclink.sign-in"

PurposeSignIn is the verification purpose of a link. Its Identifier is "<kind>:<normalized value>": the link is bound to the identifier it was sent to, not to an account, because the account may not exist yet. Its Meta is the tenant (auth.WithScope) the link was sent in, or "", followed by metaSeparator and the new user's name when the request gave one.

Variables

View Source
var (
	ErrNoKinds  = errors.New("magiclink: no identifier kind can receive a link; add a Verifiable kind whose plugin delivers, such as email")
	ErrNoBase   = errors.New("magiclink: links need auth.WithBaseURL or magiclink.Links")
	ErrBadKinds = errors.New("magiclink: a kind given to Kinds must be registered, sign-in capable, verifiable and owned by a plugin that delivers")
	// ErrCallbackInvalid refuses a callbackURL, newUserCallbackURL or
	// errorCallbackURL that is not a page of this site or of a trusted
	// origin (auth.Auth.TrustsReturnURL), at send and at verify, so the GET
	// route is never an open redirect.
	//ds:def id=magiclink-errcallbackinvalid-7tq2mw5x owner=@khanakia stability=stable
	ErrCallbackInvalid = auth.NewError("magiclink.callback_invalid", http.StatusBadRequest, "This page cannot be opened after signing in.")
)

Configuration errors auth.New returns, so a setup that could never send a link fails at start rather than at the first sign-in.

Functions

This section is empty.

Types

type Option

type Option func(*Plugin)

Option configures the plugin; options are applied in order by New.

func GenerateToken

func GenerateToken(fn TokenGenerator) Option

GenerateToken replaces the core's random 256-bit token with fn's, better-auth's generateToken. It is a plain override: whatever fn returns is the token, and an error from fn fails the send. Off by default.

Risk: a link token is found by its hash alone, with no attempt counter, so a guessable fn signs in whoever guesses it.

func Kinds

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

Kinds limits links to these identifier kinds. By default every registered kind that can sign in and has a verification flow accepts links.

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

Links builds the URL a message carries, for an app whose sign-in page is not DefaultPage. The page must POST the token; a GET must not spend it, unless the app chose VerifyByGET and its risk.

func SignUp

func SignUp() Option

SignUp lets a link create an account for an identifier nobody has verified. Without it, a link to an unknown identifier is not sent at all, and the answer is the same either way.

func VerifyByGET

func VerifyByGET() Option

VerifyByGET adds better-auth's GET sign-in: the link in the message is BaseURL + DefaultMountPrefix + "/magiclink/verify?token=…" with the send request's callbackURL, newUserCallbackURL and errorCallbackURL, and that GET spends the token, signs in and redirects: to newUserCallbackURL when the link created the account (SignUp), else to callbackURL, and on a bad link to errorCallbackURL (or callbackURL) with ?error=<code>. Without a callbackURL it answers JSON like POST sign-in. Links still overrides the link.

Off by default, and the risk is why: a GET spends the token, so a mail scanner or a chat preview that fetches the URL signs in itself and the person then gets "this link has expired"; and a GET sign-in cannot be origin-checked, so a link to the attacker's own account signs a victim into it (login CSRF), which auth.WithFetchMetadata, judging mutations only, does not cover. The default page, which POSTs the token, has neither problem. Every callback must pass auth.Auth.TrustsReturnURL.

func WithIPLimit

func WithIPLimit(n int, window time.Duration) Option

WithIPLimit replaces the default of 20 links per IP per hour, which bounds one client cycling through many identifiers; raise it behind a shared NAT.

func WithSendLimit

func WithSendLimit(n int, window time.Duration) Option

WithSendLimit replaces the default of 3 links per identifier per 15 minutes. Every link is a mail or a text to somebody, so this is what keeps the route from flooding an inbox.

func WithTTL

func WithTTL(d time.Duration) Option

WithTTL overrides DefaultTTL, how long a link works.

type Plugin

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

Plugin is the magic-link sign-in method. Build it with New: the zero value has no TTL and no operations.

func New

func New(opts ...Option) *Plugin

New builds the plugin. With no options, every sign-in kind that can be verified and delivered to accepts links, and a link never creates an account.

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. Validate cannot see that: the configuration lists kinds, not the plugins behind them.

func (*Plugin) Name

func (p *Plugin) Name() string

Name returns PluginName, the key the core registers this plugin under.

func (*Plugin) Operations

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

Operations lists send and sign-in; both are public, since the link is the proof of identity.

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) RateLimits

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

RateLimits bounds links per identifier and per IP, so the send endpoint cannot flood an inbox or be used to spam many.

func (*Plugin) Send

func (p *Plugin) Send(ctx context.Context, identifier string) error

Send delivers a sign-in link when the identifier may receive one, and answers the same when it may not.

func (*Plugin) SendAs

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

SendAs is Send with the display name a new account gets (SignUp); the name is ignored when the identifier already has an owner.

func (*Plugin) SendWith

func (p *Plugin) SendWith(ctx context.Context, in SendRequest) error

SendWith is Send with the whole request: the display name, metadata and the GET sign-in's pages (VerifyByGET).

func (*Plugin) SendWithMetadata

func (p *Plugin) SendWithMetadata(ctx context.Context, identifier string, metadata map[string]string) error

SendWithMetadata is Send with metadata the sender receives beside the link; see SendRequest.Metadata.

func (*Plugin) SignIn

func (p *Plugin) SignIn(ctx context.Context, t auth.Token) (SignInResult, error)

SignIn spends a link's token and starts a session for the identifier's verified owner, or creates one with SignUp.

func (*Plugin) SignsIn

func (p *Plugin) SignsIn() bool

SignsIn reports that a link is a way in, so an app with email and magic links and no password passes the core's "someone can sign in" check.

func (*Plugin) Validate

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

Validate refuses a configuration with no kind that can carry a link, and a rate limit that could not count.

type SendRequest

type SendRequest struct {
	Identifier string `json:"identifier"`
	// Name is the display name for the account the link creates (SignUp);
	// it travels in the token and is ignored when the identifier has an owner.
	Name string `json:"name,omitempty"`
	// Metadata travels to the delivering plugin's sender with the link (an
	// invite id, a page to return to) and is not stored. At most
	// email.MaxMetadataEntries entries of email.MaxMetadataLength bytes.
	Metadata map[string]string `json:"metadata,omitempty"`
	// CallbackURL, NewUserCallbackURL and ErrorCallbackURL are where the GET
	// sign-in (VerifyByGET) lands: after a sign-in, after an account was
	// created, after a failure. They travel in the link, are refused with
	// ErrCallbackInvalid unless auth.Auth.TrustsReturnURL accepts them, and
	// are ignored without VerifyByGET or with Links.
	CallbackURL        string `json:"callbackURL,omitempty"`
	NewUserCallbackURL string `json:"newUserCallbackURL,omitempty"`
	ErrorCallbackURL   string `json:"errorCallbackURL,omitempty"`
}

SendRequest names where the link goes: any sign-in identifier, as typed; its kind is detected.

func (SendRequest) RateIdentifier

func (r SendRequest) RateIdentifier() string

RateIdentifier counts links per identifier, folded like the email normalizer so case changes do not reset the limit.

type SignInRequest

type SignInRequest struct {
	Token auth.WireToken `json:"token"`
}

SignInRequest carries the token from the link, which the page POSTs.

type SignInResult

type SignInResult struct {
	User auth.UserView `json:"user"`
	// Created is true when the link created the account (SignUp).
	Created              bool       `json:"created"`
	SecondFactorRequired bool       `json:"secondFactorRequired"`
	Token                auth.Token `json:"-"`
}

SignInResult is the output of sign-in. Token is for Go callers and bearer transports, never encoded into JSON.

type TokenGenerator

type TokenGenerator func(ctx context.Context, identifier string) (string, error)

TokenGenerator returns the token a link carries instead of the core's random one, given the normalized identifier the link goes to; better-auth's generateToken(email).

type VerifyRequest

type VerifyRequest struct {
	Token              auth.WireToken `json:"token"`
	CallbackURL        string         `json:"callbackURL,omitempty"`
	NewUserCallbackURL string         `json:"newUserCallbackURL,omitempty"`
	ErrorCallbackURL   string         `json:"errorCallbackURL,omitempty"`
}

VerifyRequest is the query of the GET sign-in (VerifyByGET): the token and the pages SendRequest named.

Jump to

Keyboard shortcuts

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