magiclink

package
v1.12.0 Latest Latest
Warning

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

Go to latest
Published: Nov 14, 2026 License: MIT Imports: 24 Imported by: 0

README

Passwordless sign-in over email. The user asks for a link, clicks it, and they are in. Turn on the email code and the same email also carries a 6-digit code they can type instead. Turn on sign-up and a new address gets an account with no password.

There's no new table for this. Tokens ride on the core verification entity with type="magic_link", so enabling the plugin does not cost you a migration. You supply a Mailer and the plugin handles minting, expiry and single use.

The code is not a new table either. It lives in the ceremony store (the shared KV table), hashed and tied to the link it was sent with.

Every endpoint is rate limited, /send against the resend-verification budget and /verify and /verify-code against the verify-email budget, so nobody can use your auth server to bomb an inbox.

Use it when

  • You want signup with as little friction as possible, and no password to reset later
  • Support cost from forgotten passwords is a real line item for you
  • Email is already the identity of record for your users

Skip it when

  • You have no reliable transactional email. This plugin is exactly as available as your mail provider
  • Your users share an inbox, which turns a magic link into a shared credential
  • Sign-in has to work offline or on a device with no mail client. passkey handles that better

Wiring

authsome.WithPlugin(magiclink.New(magiclink.Config{
    Mailer:   myMailer, // implements magiclink.Mailer
    TokenTTL: 10 * time.Minute,
}))

Mailer is required. Without it the plugin has no way to deliver anything.

For the code, sign-up, or a different email per purpose, implement CodeMailer too:

type CodeMailer interface {
    Mailer
    SendMagicLinkWithCode(ctx context.Context, msg magiclink.MagicLinkMessage) error
}

type MagicLinkMessage struct {
    AppID     string
    Email     string
    Token     string    // goes in the link; the client posts it to /verify
    Code      string    // 6 digits, empty unless the email code is on
    Purpose   Purpose   // "signin" or "signup"
    ExpiresAt time.Time // when both the link and the code stop working
    Name      string    // the user's name, when the account has one
}

When the Mailer implements it, the plugin calls SendMagicLinkWithCode and never SendMagicLink. The code is only minted for a mailer that implements it.

Config

Field Type Default What it does
Mailer Mailer none Sends the link. Required
TokenTTL time.Duration 10m How long a link stays valid
SessionTokenTTL time.Duration 1h Lifetime of the session the link creates
SessionRefreshTTL time.Duration 30d Lifetime of that session's refresh token
EmailCode bool false Also send a 6-digit code. Needs a CodeMailer
AllowSignUp bool false Let /send with create_user create a passwordless account

Settings

Key Default
magiclink.token_ttl_seconds 600
magiclink.session_token_ttl_seconds 3600
magiclink.session_refresh_ttl_seconds 2592000
magiclink.email_code false (unset, Config.EmailCode decides)
magiclink.allow_signup false (unset, Config.AllowSignUp decides)

Endpoints

Registered under /v1/magic-link.

Method Path Purpose
POST /send Mint a token (and code) and email them
POST /verify Exchange the token for a session
POST /verify-code Exchange the emailed code for a session

/send takes {"email", "app_id"?, "create_user"?, "name"?, "captcha_token"?} and answers {"status": "magic link sent", "expires_in": 600, "code_length": 6}. code_length is there only when a code is sent. The answer is the same whether or not the address has an account, and nothing is sent to an address with none.

/verify takes {"token"} and /verify-code takes {"email", "code"}. Both answer {"user", "session_token", "refresh_token", "expires_at"}, or the usual 403 with "type": "mfa_required" and an mfa_ticket when the user has a second factor. Using either one proves the inbox, so the email is marked verified.

The link and the code are one credential: they share one lifetime, and whichever is used first uses up the other. A code allows five tries, right or wrong, and a resend replaces it. An unknown address, a wrong code, a used one and an expired one all get the same 401.

Passwordless sign-up

With AllowSignUp (or magiclink.allow_signup) on and sign-up enabled for the app, a send with "create_user": true creates an account with no password for an address nobody has, and the email goes out with purpose signup. An address that has an account gets an ordinary sign-in link and the same answer.

The account goes through the engine's sign-up path (Engine.SignUpPasswordless), so the waitlist and any other BeforeSignUp hook, the app's sign-up switch, custom form rules, email normalization and the per-address send limit apply as they do for /v1/signup. The sign-up captcha is asked for too, from the X-Captcha-Token header or captcha_token in the body, and only for a send with create_user. A sign-up that is off or refused is a 403, decided before the address is looked up.

Such an account can't sign in at /v1/signin with any password until forgot and reset password give it one.

Lifecycle hooks

None. The plugin is a RouteProvider and a SettingsProvider, and stays out of the core sign-in path.

passkey is the other passwordless option, and it does not depend on your mail provider.

One thing to watch: magiclink.Mailer is its own one-method interface, separate from the engine-wide bridge.Mailer that email and the core flows use. You wire it on this plugin's config, not with authsome.WithMailer.

Documentation

Overview

contract.go: Wires the magiclink plugin into the forge-dashboard contract surface via plugin.ContractContributor.

Package magiclink provides passwordless magic link authentication for AuthSome.

The magic link plugin allows users to sign in by clicking a one-time link sent to their email. It uses the core verification entity (type="magic_link") for token storage, so no additional database tables are required.

Usage:

mailer := &MyMailer{} // implements magiclink.Mailer
eng, _ := authsome.NewEngine(
    authsome.WithStore(store),
    authsome.WithPlugin(magiclink.New(magiclink.Config{
        Mailer: mailer,
    })),
)

Index

Constants

View Source
const MaxCodeAttempts = 5

MaxCodeAttempts caps the tries against one emailed code. Every try counts, and the code is discarded once they are spent: a six-digit code is a million guesses, and this keeps the odds of one landing at five in a million per email sent. The route limiter is the other defence, but it is absent when rate limiting is off, so the code bounds its own attempts the way an MFA ticket does.

View Source
const VerificationTypeMagicLink account.VerificationType = "magic_link"

VerificationTypeMagicLink is the verification type used for magic link tokens.

Variables

View Source
var (
	// SettingTokenTTLSeconds controls the magic link token lifetime in seconds.
	SettingTokenTTLSeconds = settings.Define("magiclink.token_ttl_seconds", 600,
		settings.WithDisplayName("Token TTL (seconds)"),
		settings.WithDescription("Lifetime of magic link tokens in seconds"),
		settings.WithCategory("Magic Link"),
		settings.WithScopes(settings.ScopeGlobal, settings.ScopeApp),
		settings.WithInputType(formconfig.FieldNumber),
		settings.WithUIValidation(formconfig.Validation{Required: true, Min: intPtr(60), Max: intPtr(3600)}),
		settings.WithHelpText("How long magic link tokens remain valid. Default: 600 (10 minutes)"),
		settings.WithOrder(10),
	)

	// SettingSessionTokenTTLSeconds controls the session token lifetime for magic link sessions.
	SettingSessionTokenTTLSeconds = settings.Define("magiclink.session_token_ttl_seconds", 3600,
		settings.WithDisplayName("Session Token TTL (seconds)"),
		settings.WithDescription("Lifetime of sessions created via magic link in seconds"),
		settings.WithCategory("Magic Link"),
		settings.WithScopes(settings.ScopeGlobal, settings.ScopeApp),
		settings.WithInputType(formconfig.FieldNumber),
		settings.WithUIValidation(formconfig.Validation{Required: true, Min: intPtr(300), Max: intPtr(86400)}),
		settings.WithHelpText("How long sessions created via magic link remain valid. Default: 3600 (1 hour)"),
		settings.WithOrder(20),
	)

	// SettingSessionRefreshTTLSeconds controls the refresh token lifetime for magic link sessions.
	SettingSessionRefreshTTLSeconds = settings.Define("magiclink.session_refresh_ttl_seconds", 2592000,
		settings.WithDisplayName("Refresh Token TTL (seconds)"),
		settings.WithDescription("Lifetime of refresh tokens for magic link sessions in seconds"),
		settings.WithCategory("Magic Link"),
		settings.WithScopes(settings.ScopeGlobal, settings.ScopeApp),
		settings.WithInputType(formconfig.FieldNumber),
		settings.WithUIValidation(formconfig.Validation{Required: true, Min: intPtr(3600), Max: intPtr(7776000)}),
		settings.WithHelpText("How long refresh tokens remain valid. Default: 2592000 (30 days)"),
		settings.WithOrder(30),
	)

	// SettingEmailCode turns on the 6-digit sign-in code that goes out in the
	// same email as the link. Unset, Config.EmailCode decides.
	SettingEmailCode = settings.Define("magiclink.email_code", false,
		settings.WithDisplayName("Email a sign-in code"),
		settings.WithDescription("Send a 6-digit code alongside the magic link"),
		settings.WithCategory("Magic Link"),
		settings.WithScopes(settings.ScopeGlobal, settings.ScopeApp),
		settings.WithInputType(formconfig.FieldSwitch),
		settings.WithHelpText("The code and the link share one lifetime, and using either one uses up both. Needs a mailer that implements magiclink.CodeMailer."),
		settings.WithOrder(40),
	)

	// SettingAllowSignUp lets /v1/magic-link/send create an account for an
	// unknown address when the request asks for it. Unset,
	// Config.AllowSignUp decides.
	SettingAllowSignUp = settings.Define("magiclink.allow_signup", false,
		settings.WithDisplayName("Passwordless sign-up"),
		settings.WithDescription("Create an account with no password when someone asks for a link with create_user"),
		settings.WithCategory("Magic Link"),
		settings.WithScopes(settings.ScopeGlobal, settings.ScopeApp),
		settings.WithInputType(formconfig.FieldSwitch),
		settings.WithHelpText("Sign-up still has to be enabled for the app, and the sign-up hooks (waitlist, captcha) still apply."),
		settings.WithOrder(50),
	)
)

Functions

This section is empty.

Types

type CodeMailer added in v1.12.0

type CodeMailer interface {
	Mailer
	SendMagicLinkWithCode(ctx context.Context, msg MagicLinkMessage) error
}

CodeMailer is a Mailer that can also deliver the sign-in code and knows why the email is going out. When the configured Mailer implements it, the plugin calls SendMagicLinkWithCode instead of SendMagicLink. The email code is only minted for a mailer that implements it.

type Config

type Config struct {
	// Mailer sends magic link emails. Required. Implement CodeMailer as well
	// to deliver the email code and the purpose of the email.
	Mailer Mailer

	// EmailCode also mints a 6-digit code with every link, delivered in the
	// same email, which the user can type at /v1/magic-link/verify-code.
	// The code and link share one lifetime and using either uses up both.
	// It needs a Mailer that implements CodeMailer. The
	// magiclink.email_code setting overrides it per app. Default: false.
	EmailCode bool

	// AllowSignUp lets /v1/magic-link/send create an account with no
	// password when the request carries create_user and no account has the
	// address. Sign-up must also be enabled for the app. The
	// magiclink.allow_signup setting overrides it per app. Default: false.
	AllowSignUp bool

	// TokenTTL is the lifetime of magic link tokens (default: 10 minutes).
	TokenTTL time.Duration

	// SessionTokenTTL is the lifetime of sessions created via magic link (default: 1 hour).
	SessionTokenTTL time.Duration

	// SessionRefreshTTL is the lifetime of refresh tokens for magic link sessions (default: 30 days).
	SessionRefreshTTL time.Duration
}

Config configures the magic link plugin.

type MagicLinkMessage added in v1.12.0

type MagicLinkMessage struct {
	// AppID is the app the link signs in to.
	AppID string
	// Email is the recipient.
	Email string
	// Token goes in the link. The client posts it to /v1/magic-link/verify.
	Token string
	// Code is the 6-digit code the user can type instead of clicking. It is
	// empty unless the email code is on for the app.
	Code string
	// Purpose is PurposeSignIn or PurposeSignUp, so the email can greet a
	// new account differently.
	Purpose Purpose
	// ExpiresAt is when the link and the code stop working.
	ExpiresAt time.Time
	// Name is the user's name, when the account has one.
	Name string
}

MagicLinkMessage is everything a CodeMailer needs to write the email.

type Mailer added in v1.9.0

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

Mailer sends magic link emails.

type MailerFunc added in v1.9.0

type MailerFunc func(ctx context.Context, email, token string) error

MailerFunc is an adapter to use a plain function as a Mailer.

func (f MailerFunc) SendMagicLink(ctx context.Context, email, token string) error

SendMagicLink implements Mailer.

type Plugin

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

Plugin is the magic link authentication plugin.

func New added in v1.9.0

func New(cfg ...Config) *Plugin

New creates a new magic link plugin.

func (*Plugin) DeclareSettings added in v1.9.0

func (p *Plugin) DeclareSettings(m *settings.Manager) error

DeclareSettings implements plugin.SettingsProvider.

func (*Plugin) Name added in v1.9.0

func (p *Plugin) Name() string

Name returns the plugin name.

func (*Plugin) OnInit added in v1.9.0

func (p *Plugin) OnInit(_ context.Context, engine plugin.Engine) error

OnInit captures the store reference from the engine.

func (*Plugin) RegisterContract added in v1.9.0

func (p *Plugin) RegisterContract(
	d *dispatcher.Dispatcher,
	reg contract.Registry,
	wreg contract.WardenRegistry,
	engine plugin.Engine,
) error

func (*Plugin) RegisterRoutes

func (p *Plugin) RegisterRoutes(router forge.Router) error

RegisterRoutes registers magic link HTTP endpoints on a forge.Router.

func (*Plugin) SetAppID added in v1.9.0

func (p *Plugin) SetAppID(appID string)

SetAppID sets the app ID for the plugin (used in testing).

func (*Plugin) SetStore added in v1.9.0

func (p *Plugin) SetStore(s store.Store)

SetStore allows direct store injection for testing.

type Purpose added in v1.12.0

type Purpose string

Purpose says why a magic link email went out.

const (
	// PurposeSignIn is a link for an account that already exists.
	PurposeSignIn Purpose = "signin"
	// PurposeSignUp is a link for an account created by this request, or
	// one created earlier that has never proved its inbox.
	PurposeSignUp Purpose = "signup"
)

type SendRequest

type SendRequest struct {
	Email string `json:"email"`
	AppID string `json:"app_id,omitempty"`
	// CreateUser asks for an account to be created, with no password, when
	// no account has the address. It needs passwordless sign-up to be on.
	CreateUser bool `json:"create_user,omitempty"`
	// Name is the new account's name, used only when one is created.
	Name string `json:"name,omitempty"`
	// CaptchaToken is the captcha answer for a sign-up, when the app
	// requires one. The X-Captcha-Token header works too.
	CaptchaToken string `json:"captcha_token,omitempty"`
}

SendRequest is the request body for sending a magic link.

type SendResponse

type SendResponse struct {
	Status string `json:"status"`
	// ExpiresIn is how many seconds the link (and the code) stay valid.
	ExpiresIn int `json:"expires_in"`
	// CodeLength is the number of digits in the emailed code, or absent
	// when no code is sent.
	CodeLength int `json:"code_length,omitempty"`
}

SendResponse is returned when a magic link is sent. It is the same whether or not an account has the address.

type VerifyCodeRequest added in v1.12.0

type VerifyCodeRequest struct {
	Email string `json:"email"`
	Code  string `json:"code"`
	AppID string `json:"app_id,omitempty"`
}

VerifyCodeRequest is the request body for signing in with the emailed code.

type VerifyRequest added in v1.9.0

type VerifyRequest struct {
	Token string `json:"token"`
	AppID string `json:"app_id,omitempty"`
}

VerifyRequest is the request body for verifying a magic link.

type VerifyResponse

type VerifyResponse struct {
	User         any    `json:"user"`
	SessionToken string `json:"session_token"`
	RefreshToken string `json:"refresh_token"`
	ExpiresAt    any    `json:"expires_at"`
}

VerifyResponse is returned when a magic link is verified.

Directories

Path Synopsis
Package contract wires the magiclink plugin's deep-link settings page into the forge-dashboard contract registry.
Package contract wires the magiclink plugin's deep-link settings page into the forge-dashboard contract registry.

Jump to

Keyboard shortcuts

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