phone

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/phone

Adds phone numbers (E.164) to accounts: numbers verified by SMS code, and optionally sign-in, and sign-up, with a code alone, with defences against SMS pumping.

Install

go get github.com/ubgo/auth

The package is part of the core module, so it adds no dependency. You provide the SMS transport by implementing phone.SMSSender (or wrapping a function in phone.SMSFunc) over your provider. For numbers typed without a country code, see github.com/ubgo/auth/contrib/phonenumbers, a separate module on libphonenumber.

Wire it

From examples/phone-only, a phone-only app:

a, err := auth.New(
	auth.WithDB(db, sqlb.SQLiteDialect{}),
	auth.WithBaseURL(baseURL),
	auth.WithPlugins(
		phone.New(phone.Login(), phone.Recover(), phone.SignInWithCode(), phone.WithSMS(sms), phone.AllowPrefixes(allowedPrefixes...)),
	),
)

With no options, a number is unique among verified rows and can be verified, but it does not sign in or recover. The plugin is a sign-in method only with Login() and SignInWithCode() together. Without both, pair it with another method, or auth.New fails with auth: no sign-in method; add a plugin such as password or oauth.

Validate makes auth.New fail with:

Error Text Cause
ErrNoSMS phone: no SMS sender; pass phone.WithSMS WithSMS missing
ErrCodeNoLogin phone: SignInWithCode needs Login() code sign-in on a kind that cannot sign in

Options

Option What it does Default
Login() The number can sign in; auth.New then requires it unique off
Recover() The number receives recovery messages; only a verified one off
Contact() Contact only: several users may share a number, and it neither signs in nor recovers off (unique among verified)
ScopedByTenant() Numbers unique per tenant (auth.WithScope) global
RequireVerified() Refuse sign-in through an unverified number off
Multiple() Several numbers per user, one primary: add accepts another, verify makes a number primary only when there is no verified primary, confirm-change keeps the old numbers, and list and make-primary are registered one number per user
ResendOnSignIn() SendVerification texts a fresh verify code; the core calls it when a correct password is refused because the number is unverified, so the user gets a code again without a resend off: nothing is sent at sign-in
SignInWithCode() Adds send-code and sign-in: a code signs in the verified owner, or creates the account off
WithSMS(s) How texts are sent. Required none
WithSMS(s) with a MetadataSender A sender that also implements SendWithMetadata(ctx, to, text, metadata) receives the metadata a magic-link request attached (MetadataSender); a plain SMSSender gets the text alone the text alone
WithVerifier(v) A provider's verification service (Twilio Verify, say) makes, texts and checks the codes for verify, sign-in and change; the provider's attempt counter and lifetime apply instead of WithMaxAttempts and WithCodeTTL, the send limits still apply, and messages that are not codes still go through WithSMS (Verifier) the core's codes
GenerateCode(fn) fn makes every texted code (verify, change, sign-in) instead of random digits, told the number and the purpose: a fixed code for a review account, another alphabet. Compared exactly; an error fails the send; the core refuses an empty code or one over auth.MaxGeneratedCodeLength. The strength is yours. Refused beside WithVerifier (ErrGenerateWithVerifier) auth.WithCodeLength random digits (4 to 10)
WithCodeTTL(d) Code lifetime; the default text states it DefaultCodeTTL = 10 minutes
WithMaxAttempts(n) Wrong guesses one code survives DefaultMaxAttempts = 5
WithNormalizer(fn) Replaces Normalize; the result must be E.164 (+ and digits) Normalize (needs the + country code)
WithValidator(fn) Replaces Validate; AllowPrefixes still applies on top digit count MinDigits (7) to MaxDigits (15)
AllowPrefixes(p...) Accept only numbers starting with one of the prefixes, on every path every prefix
LimitRange(digits, limit, window) Also count add, resend and send-code per number range (the first digits characters, + included) none
Templates(fn) Replaces the English texts DefaultTemplate, with the configured TTL
TemplatesByLocale(map) Translations picked by auth.PickLocale; falls back to Templates none

A text for another plugin's purpose uses that plugin's auth.Message.DefaultText, or the bare link without it.

Routes and Go API

The routes are under your mount prefix (for example /auth). The rate limits are keyed by the normalized number unless marked per IP. LimitRange adds its own rules on top of these.

Route Go method Requirement Rate limit
POST /phone/add Add(ctx, number) SignedIn 3 per 10 minutes (sendCodeLimit, sendCodeWindow)
POST /phone/verify Verify(ctx, number, code) SignedIn none (the per-code attempt cap applies)
POST /phone/resend Resend(ctx, number) SignedIn 3 per 10 minutes (sendCodeLimit, sendCodeWindow)
POST /phone/remove Remove(ctx, number) FreshSignIn none
POST /phone/change (phone) Change(ctx, number) FreshSignIn 3 per 10 minutes per number (sendCodeLimit, sendCodeWindow), and LimitRange
POST /phone/confirm-change (phone, code) ConfirmChange(ctx, number, code) returns ChangeResult{Phone} SignedIn 10 per 15 minutes per number (signInLimit, signInWindow)
GET /phone/list List(ctx) returns []Number{Phone, Verified, Primary} SignedIn none; only with Multiple()
POST /phone/make-primary MakePrimary(ctx, number); an unverified number is auth.ErrNotVerified FreshSignIn none; only with Multiple()
POST /phone/send-code SendCode(ctx, number) Public 3 per 10 minutes per number; 20 per hour per IP (sendCodeIPLimit, sendCodeIPWin)
POST /phone/sign-in (phone, code, optional sign-up fields at the top level or in fields) SignIn(ctx, number, code), SignInWith(ctx, SignInRequest{…, Fields}) Public 10 per 15 minutes (signInLimit, signInWindow)

send-code and sign-in exist only with Login() and SignInWithCode(). The operation ids are exported as Endpoint* constants.

Behaviour worth knowing:

  • Without Multiple() a user has at most one number; add of a second is ErrHasNumber. With it, add of another number texts it a code, and add of one the user has is a no-op.
  • Any new unverified number is texted a code, however it was created.
  • verify makes the number verified and primary; under Multiple() primary only when the user has no verified primary yet, and make-primary moves it.
  • remove of the primary makes the oldest remaining verified number primary.
  • resend answers OK when the user has no such number, or has already verified it.
  • send-code texts any valid number, whether or not it has an account. A malformed number is refused with auth.ErrIdentifierInvalid.
  • sign-in signs in the verified owner, or creates a user whose number this code verified.

Codes have auth.DefaultCodeLength (6) digits unless the app sets auth.WithCodeLength.

Tables and columns

The plugin owns no table and ships no migration: codes are the core's one-time codes, in the core auth_code table.

  • Numbers: each number is a row of the core auth_identity table with kind = 'phone', plus one unique index in the core's 0000_identity_unique_phone_<mode> migration when the kind is unique.
  • Composite user queries: the plugin reads auth_identity under the alias phi (TableAliasIdentity). Add phone.UserJoin() and phone.UserBindings(&u.Phone) to your user query to fill a nested phone.Data (Number, NumberRaw, VerifiedAt, IsPrimary). phone.Of(a, userID, &d) builds the standalone lookup.

stability_test.go lists every auth_identity column these read (user_id, kind, value, value_raw, verified_at, is_primary) as stable.

Errors

Error Code HTTP When
phone.ErrHasNumber phone.has_number 409 add when the user already has a number
phone.ErrCodeInvalid (= auth.ErrCodeInvalid) auth.code_invalid 400 a wrong, spent or expired code, no such number on verify, a malformed number on sign-in
auth.ErrIdentifierInvalid auth.identifier_invalid 400 not a number, wrong digit count, or outside AllowPrefixes
auth.ErrIdentityTaken auth.identity_taken 409 another account verified the number after this code was texted (verify, or confirm-change, which then changes nothing); an add, resend or change for a number already verified elsewhere texts that owner the duplicate sign-up notice instead of a code, and answers the requester as for a free number
auth.ErrIdentityNotFound auth.identity_not_found 404 remove of a number the user does not have
auth.ErrLastSignInMethod auth.last_sign_in_method 409 remove of the user's last way in
auth.ErrRateLimited auth.rate_limited 429 a limit in the table above

ErrNoSMS, ErrCodeNoLogin and ErrGenerateWithVerifier are configuration errors from auth.New.

Security notes

Property Pinned by
A code works once; WithMaxAttempts wrong guesses spend it, counted even though the failed call rolls back integration/TestPhone_CodeIsSingleUseAndAttemptsAreCapped
Under Multiple() the first verified number stays primary, make-primary moves it, a confirmed change keeps the others, removing the primary promotes another; without it a second number is ErrHasNumber integration/TestR23_PhoneMultiple, plugin/phone/TestMultiple_RegistersListAndMakePrimary
Code sign-in creates the account once, then finds the same user; formatting variants are one number integration/TestPhone_SignInWithCodeCreatesThenFindsTheUser, plugin/phone/TestNormalize
SMS pumping: the range counter stops a bot rotating numbers and IPs, and numbers outside AllowPrefixes are refused before any text integration/TestPhone_RangeLimitAndAllowedPrefixes
One number per user; refusals change nothing integration/TestIdentPhone_NumberLifecycle
GenerateCode codes are texted and checked exactly, the generator sees the number and purpose, its error fails the send, and it is refused beside a Verifier integration/TestPhone_GenerateCodeTextsAndChecksTheGeneratedCode, plugin/phone/TestGenerateCode_RefusedBesideVerifier
The text never promises more time than WithCodeTTL gives plugin/phone/TestDefaultText_StatesTheConfiguredTTL
change texts a code to the new number and changes nothing until confirm-change, which replaces the number in one transaction; the code is bound to the number; a code works once; change needs a fresh sign-in integration/TestPhone_ChangeReplacesTheNumberInOneStep
change sets a first number, verifies an own unverified number in place, and a number another account verified after the code was texted is refused with nothing changed integration/TestPhone_ChangeEdgeCases
Enumeration: add, resend and change for a number another account has verified answer as for a free number, text no code (with a Verifier, never ask the provider) and text that owner the duplicate sign-up notice instead, so verify and confirm-change can only answer auth.code_invalid integration/TestR26_PhoneTakenNumberGetsNoCode
With a Verifier no code goes through the SMSSender; the provider's verdict decides, and its failure fails the operation rather than refusing the user integration/TestPhone_Verifier
Every database failure fails the operation integration/TestIdentFaults_Phone, integration/TestIdentFaults_PhoneRound2
fields on sign-in sets only auth.WithSignUpFields columns, only on the account the code creates; an unknown or wrong-typed field is auth.field_invalid before the code is checked integration/TestR21_PhoneSignInFields

Limits:

  • Validate checks the digit count only, not whether the number exists; pass WithValidator (or contrib/phonenumbers) for that.
  • send-code texts any valid number, so the IP, number and range limits are what bound SMS cost. AllowPrefixes is the first defence.
  • Texts are sent after the transaction commits, in the background. An SMSSender error is logged, not returned.

Example

Documentation

Overview

Package phone registers the phone identifier kind (E.164 numbers), verifies numbers with SMS codes, and can sign users in — and up — with a code alone.

A number is a row of the core auth_identity table, so one uniqueness policy and one verified check cover it with every other kind. The codes are the core's one-time codes (auth.IssueCode, auth.CheckCode), shared with the email plugin, so the attempt counter and single use live in one place.

Index

Constants

View Source
const (
	EndpointAdd           auth.EndpointID = "phone.add"
	EndpointVerify        auth.EndpointID = "phone.verify"
	EndpointResend        auth.EndpointID = "phone.resend"
	EndpointRemove        auth.EndpointID = "phone.remove"
	EndpointSendCode      auth.EndpointID = "phone.send-code"
	EndpointSignIn        auth.EndpointID = "phone.sign-in"
	EndpointChange        auth.EndpointID = "phone.change"
	EndpointConfirmChange auth.EndpointID = "phone.confirm-change"
	// EndpointList and EndpointMakePrimary are registered only under
	// Multiple: with one number per account there is nothing to list or
	// choose between.
	EndpointList        auth.EndpointID = "phone.list"
	EndpointMakePrimary auth.EndpointID = "phone.make-primary"
)

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

View Source
const (
	PurposeVerifyCode auth.Purpose = "phone.verify"
	PurposeSignInCode auth.Purpose = "phone.sign_in"
	PurposeChangeCode auth.Purpose = "phone.change"
)

Code purposes. The subject of a verify code is the identity id; of a sign-in code, the normalized number, because the account may not exist; of a change code, the user id and the new number (changeSubject), because no row exists for the new number until the code is checked.

View Source
const (
	//ds:def id=defaultcodettl-38ny9rrh owner=@khanakia stability=stable
	DefaultCodeTTL = 10 * time.Minute
	//ds:def id=defaultmaxattempts-9thd5u9b owner=@khanakia stability=stable
	DefaultMaxAttempts = 5
)

Code defaults. The TTL is short because an SMS arrives in seconds; the attempt cap exists because a six-digit code is a million guesses, nothing to a script and everything to a counter.

View Source
const (
	//ds:def id=mindigits-s68ve98t owner=@khanakia stability=stable
	MinDigits = 7
	//ds:def id=maxdigits-9yc3z2gc owner=@khanakia stability=stable
	MaxDigits = 15
)

E.164 bounds: the shortest plausible international number and the maximum.

View Source
const KindPhone auth.IdentifierKindName = "phone"

KindPhone is the identifier kind this plugin registers. It is stored in every auth_identity row this plugin owns, so it never changes.

View Source
const PluginName = "phone"

PluginName is the plugin's permanent identity.

View Source
const TableAliasIdentity = "phi"

TableAliasIdentity is this plugin's alias for the shared core identity table; a composite user query joins that table once per identifier kind, and each occurrence needs its own alias.

Variables

Typed columns on the identity table under this plugin's alias, for UserJoin and Of; the same columns as the core's, qualified by "phi".

View Source
var (
	// ErrCodeInvalid is the core's one-time-code error, re-exported so a
	// caller of this package need not import the core to match it.
	ErrCodeInvalid = auth.ErrCodeInvalid
	ErrHasNumber   = auth.NewError("phone.has_number", http.StatusConflict, "This account already has a phone number. Remove it first.")
	ErrNoSMS       = errors.New("phone: no SMS sender; pass phone.WithSMS")
	ErrCodeNoLogin = errors.New("phone: SignInWithCode needs Login()")
)

Errors. ErrCodeInvalid and ErrHasNumber reach the user; ErrNoSMS and ErrCodeNoLogin are configuration errors auth.New returns, so a setup that could never text a code fails at start.

View Source
var ErrGenerateWithVerifier = errors.New("phone: GenerateCode has no effect with WithVerifier, whose provider makes the code")

ErrGenerateWithVerifier is GenerateCode together with WithVerifier: the provider makes and checks the code, so a generator would never be used.

View Source
var TableIdentity = sqlb.Table{Name: auth.TableNameIdentity, Alias: TableAliasIdentity}

TableIdentity is the core identity table under this plugin's alias.

Functions

func DefaultTemplate

func DefaultTemplate(m auth.Message) string

DefaultTemplate is the default English text for codes that live DefaultCodeTTL. The plugin's own default states its configured TTL instead, so WithCodeTTL never leaves a text promising the wrong lifetime; an app that passes DefaultTemplate to Templates with another TTL should write its own.

func Normalize

func Normalize(raw string) (string, error)

Normalize reduces a number to "+" and digits. Without it "+1 (555) 0100" and "+15550100" would be two rows and the unique index would prevent nothing. A number must be typed with its country code: guessing a country is a product decision, made by WithNormalizer-style wrapping in the app.

func Of

func Of(a *auth.Auth, userID string, dst *Data) (sqlb.Query, error)

Of builds the standalone lookup for one user's primary phone data, for an app that reads it outside a composite user query.

func UserBindings

func UserBindings(dst *Data) []sqlb.Binding

UserBindings binds this plugin's columns to dst. Every column of a LEFT join must survive NULL, so strings and the flag are coalesced and VerifiedAt is a pointer, where nil is the meaningful answer.

func UserJoin

func UserJoin() sqlb.Join

UserJoin brings the primary phone identity into a composite user query. LEFT because a user may have no phone; kind and primary in the ON clause, or the result would multiply.

func Validate

func Validate(v string) error

Validate checks the digit count of a normalized number against the E.164 bounds; it cannot tell whether the number exists (see WithValidator).

Types

type ChangeResult

type ChangeResult struct {
	Phone string `json:"phone"`
}

ChangeResult is the output of confirm-change: the normalized number now verified and primary.

type CodeFor

type CodeFor struct {
	Phone   string
	Purpose auth.Purpose
}

CodeFor is what a custom code generator (GenerateCode) is told about the code it makes: the normalized number it goes to and the code's purpose, PurposeVerifyCode, PurposeChangeCode or PurposeSignInCode.

type CodeGenerator

type CodeGenerator func(ctx context.Context, in CodeFor) (string, error)

CodeGenerator returns the code to text instead of the core's random digits.

type CodeRequest

type CodeRequest struct {
	Phone string `json:"phone"`
	Code  string `json:"code"`
}

CodeRequest carries a number and the six-digit code texted to it.

func (CodeRequest) RateIdentifier

func (r CodeRequest) RateIdentifier() string

RateIdentifier counts guesses per normalized number; see NumberRequest.RateIdentifier for the malformed case.

type Data

type Data struct {
	Number     string
	NumberRaw  string
	VerifiedAt *time.Time
	IsPrimary  bool
}

Data is the phone plugin's contribution to a user, shaped as a nested edge: a number lives in auth_identity, not auth_user, so it reads as u.Phone.Number (ADR-0001). Every field is empty when the user has no number.

func (*Data) Exists

func (d *Data) Exists() bool

Exists reports whether the user has a phone identity at all. The LEFT join yields a zero Data rather than an absent one, so absence has to be asked for.

func (*Data) Verified

func (d *Data) Verified() bool

Verified reports whether the number has been proven by a code. Only a verified number signs in or receives recovery texts.

type ListResult

type ListResult struct {
	Numbers []Number `json:"numbers"`
}

ListResult is the output of list, in creation order; Numbers is never nil, so it encodes as [] rather than null.

type MetadataSender

type MetadataSender interface {
	SMSSender
	SendWithMetadata(ctx context.Context, to, text string, metadata map[string]string) error
}

MetadataSender is an SMSSender that also wants the metadata a sending operation attached to the message (auth.Message.Metadata), such as an invite id a magic link was requested with. Optional: a sender without it gets the text alone, and the library calls SendWithMetadata only when the message carries metadata.

type Number

type Number struct {
	Phone    string `json:"phone"`
	Verified bool   `json:"verified"`
	Primary  bool   `json:"primary"`
}

Number is one row of the list. Verified and Primary are separate because an unverified number is listed but can never be primary.

type NumberRequest

type NumberRequest struct {
	Phone string `json:"phone"`
}

NumberRequest names a number as the user typed it; each operation normalizes it.

func (NumberRequest) RateIdentifier

func (r NumberRequest) RateIdentifier() string

RateIdentifier counts per normalized number. A malformed number keys as "" and is refused by the operation anyway, so sharing that key costs nothing.

type Option

type Option func(*Plugin)

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

func AllowPrefixes

func AllowPrefixes(prefixes ...string) Option

AllowPrefixes accepts only numbers that start with one of prefixes, such as "+1" and "+44". Every other number is refused on every path — sign-up, add, code sign-in — as an invalid identifier. It is the first defence against SMS pumping, where bots request codes for expensive numbers abroad.

func Contact

func Contact() Option

Contact makes numbers contact information only: several users may share one, and it neither signs in nor recovers.

func GenerateCode

func GenerateCode(fn CodeGenerator) Option

GenerateCode replaces the core's random digits with fn's code, for every code this plugin texts (verify, change and sign-in), as email.GenerateCode does for mail: a fixed code for an app-store review account, a code in another alphabet, one from the app's own generator. Whatever fn returns is the code, compared exactly; an error from fn fails the send; the core refuses an empty code or one longer than auth.MaxGeneratedCodeLength. Off by default; the default draws auth.WithCodeLength digits (4 to 10) from crypto/rand. auth.New refuses it beside WithVerifier.

Risk: a code is only as hard to guess as fn makes it, and the attempt and rate limits are tuned for random digits of the configured length.

func LimitRange

func LimitRange(digits, limit int, window time.Duration) Option

LimitRange counts code requests per number range: the first digits of the number, "+" included. LimitRange(6, 20, time.Hour) allows 20 codes an hour to all numbers starting "+15550", however many numbers and IPs a bot uses. It adds to the per-number and per-IP limits, never replaces them.

func Login

func Login() Option

Login allows numbers in sign-in. auth.New then requires them unique, since a login box cannot pick between two users.

func Multiple

func Multiple() Option

Multiple allows several numbers per user, one of them primary: the one texts go to and the one a composite user query shows. Add accepts a second number instead of ErrHasNumber, a confirmed change adds the new number as primary and keeps the others, verifying a number makes it primary only when the user has no verified primary yet, and the list and make-primary routes are registered. Removing the primary promotes another verified number, so texts keep a destination.

func Recover

func Recover() Option

Recover allows numbers to receive recovery messages. Only a verified number receives one.

func RequireVerified

func RequireVerified() Option

RequireVerified refuses sign-in through an unverified number, which could be anybody's claim.

func ResendOnSignIn

func ResendOnSignIn() Option

ResendOnSignIn makes SendVerification text a fresh verify code, so a password sign-in refused because the number is unverified (RequireVerified) gets a code again without a separate resend. Off, nothing is sent.

func ScopedByTenant

func ScopedByTenant() Option

ScopedByTenant makes numbers unique per tenant, for an app where one person has a separate account in each; see auth.WithScope.

func SignInWithCode

func SignInWithCode() Option

SignInWithCode enables sign-in, and sign-up, with an SMS code alone.

func Templates

func Templates(fn func(m auth.Message) string) Option

Templates replaces the default English texts, for every locale TemplatesByLocale does not cover. It must include m.Code or m.Link.

func TemplatesByLocale

func TemplatesByLocale(byLocale map[string]func(m auth.Message) string) Option

TemplatesByLocale adds translations, picked by auth.PickLocale; a message in a language with none uses the templates set by Templates, or the English defaults. The library ships no translations.

The choice is made at delivery, so the order of this option and Templates does not matter.

func WithCodeTTL

func WithCodeTTL(d time.Duration) Option

WithCodeTTL overrides DefaultCodeTTL. The default text states the TTL set here.

func WithMaxAttempts

func WithMaxAttempts(n int) Option

WithMaxAttempts overrides DefaultMaxAttempts, the wrong guesses one code survives before it is spent.

func WithNormalizer

func WithNormalizer(fn func(raw string) (string, error)) Option

WithNormalizer replaces Normalize, for an app whose users type numbers without the country code; contrib/phonenumbers has one built on libphonenumber. The result must be E.164 ("+" and digits): it is what is stored, compared and texted.

func WithSMS

func WithSMS(s SMSSender) Option

WithSMS sets how text messages are sent. Required: auth.New fails with ErrNoSMS without it, because no number could ever be verified.

func WithValidator

func WithValidator(fn func(normalized string) error) Option

WithValidator replaces Validate, such as a country-aware check that a number can exist. AllowPrefixes still applies on top of it.

func WithVerifier

func WithVerifier(v Verifier) Option

WithVerifier delegates making, texting and checking codes to a provider's verification service; see Verifier. WithSMS is still required for the messages that are not codes.

type Plugin

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

Plugin is the phone identifier plugin. Build it with New: the zero value has no kind, no templates and no operations.

func New

func New(opts ...Option) *Plugin

New builds the plugin. With no options a number is unique among verified rows and verifiable, but neither signs in nor recovers.

func (*Plugin) Add

func (p *Plugin) Add(ctx context.Context, number string) error

Add adds an unverified number to the signed-in user and texts it a code.

func (*Plugin) Change

func (p *Plugin) Change(ctx context.Context, number string) error

Change starts replacing the signed-in user's number by texting a code to the new one; nothing changes until ConfirmChange, so the account keeps its old number in between. It also sets a first number in one step for a user who has none.

func (*Plugin) ConfirmChange

func (p *Plugin) ConfirmChange(ctx context.Context, number, code string) (ChangeResult, error)

ConfirmChange checks the code texted by Change: the new number becomes the verified primary and the old number is removed, in one transaction.

func (*Plugin) DataHooks

func (p *Plugin) DataHooks() auth.DataHooks

DataHooks send a code to every new unverified number, however it was created.

func (*Plugin) Deliver

func (p *Plugin) Deliver(ctx context.Context, to auth.Identity, m auth.Message) error

Deliver renders m in its locale's template and texts it. The core calls it after the transaction commits, so no text goes out for a rolled-back row.

With a Verifier, a code message with no code is one the provider makes: the plugin asked for a verification (issueCode) and the provider texts its own code here, through the same background path every delivery takes.

func (*Plugin) Identifiers

func (p *Plugin) Identifiers() []auth.IdentifierKind

Identifiers registers the phone kind with the core, which from then on owns its uniqueness and verified checks.

func (*Plugin) Init

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

Init captures the core handle the operations and hooks call.

func (*Plugin) List

func (p *Plugin) List(ctx context.Context) ([]Number, error)

List returns the signed-in user's numbers, verified or not. Registered only under Multiple; without it the call is auth.ErrOperationNotWired.

func (*Plugin) MakePrimary

func (p *Plugin) MakePrimary(ctx context.Context, number string) error

MakePrimary makes one of the signed-in user's verified numbers the primary one, the number texts go to. An unverified one is auth.ErrNotVerified, a number the user does not have auth.ErrIdentityNotFound. Registered only under Multiple, and it needs a fresh sign-in, as change does.

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 the operations; code sign-in only when enabled, so an app without it exposes no route that texts codes to strangers.

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 codes sent and guessed, per number, per IP, and per number range when LimitRange is set, because every text costs money.

func (*Plugin) Remove

func (p *Plugin) Remove(ctx context.Context, number string) error

Remove deletes the signed-in user's number, unless it is their last way in.

func (*Plugin) Resend

func (p *Plugin) Resend(ctx context.Context, number string) error

Resend texts a new verify code to the signed-in user's unverified number. It answers the same when the number is already verified or not theirs.

func (*Plugin) SendCode

func (p *Plugin) SendCode(ctx context.Context, number string) error

SendCode texts a sign-in code to any valid number, so the answer never reveals whether it has an account.

func (*Plugin) SendVerification

func (p *Plugin) SendVerification(ctx context.Context, to auth.Identity) error

SendVerification texts a fresh verify code for one of this plugin's unverified numbers, when ResendOnSignIn is set. The core calls it through its dispatcher after a correct password was refused for lack of verification; a verified or foreign identity is ignored.

func (*Plugin) SignIn

func (p *Plugin) SignIn(ctx context.Context, number, code string) (SignInResult, error)

SignIn checks a sign-in code and starts a session, creating the account when the number has no verified owner.

func (*Plugin) SignInWith

func (p *Plugin) SignInWith(ctx context.Context, in SignInRequest) (SignInResult, error)

SignInWith is SignIn with the whole request, for a Go caller that sets Fields on the account the code creates.

func (*Plugin) SignsIn

func (p *Plugin) SignsIn() bool

SignsIn reports whether code sign-in is on, which is what counts as a way in for the core's "someone can sign in" check.

func (*Plugin) Tables

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

Tables declares the aliased identity table, so auth.New can reject another plugin claiming the same alias.

func (*Plugin) Validate

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

Validate refuses a setup that could never text a code, that turns on code sign-in for a kind that cannot sign in, or whose LimitRange could not count.

func (*Plugin) Verify

func (p *Plugin) Verify(ctx context.Context, number, code string) error

Verify checks a code for the signed-in user's number, then marks it verified and primary. A wrong code, or no such number, is ErrCodeInvalid.

type SMSFunc

type SMSFunc func(ctx context.Context, to, text string) error

SMSFunc adapts a function to SMSSender, for an app whose sender is a closure over its provider's client.

func (SMSFunc) Send

func (f SMSFunc) Send(ctx context.Context, to, text string) error

Send calls f, so a closure satisfies SMSSender without a named type.

type SMSSender

type SMSSender interface {
	Send(ctx context.Context, to, text string) error
}

SMSSender delivers a text message. The app implements it over its provider, so the library depends on nobody's SDK.

type SignInRequest

type SignInRequest struct {
	Phone string `json:"phone"`
	Code  string `json:"code"`
	// Fields sets the auth.WithSignUpFields columns of the account a code
	// creates, each of its declared type, else auth.ErrFieldInvalid before
	// the code is checked. The same columns may also be sent as top-level
	// keys, as better-auth does; one sent both ways is auth.ErrFieldInvalid.
	// Ignored when the number already has an owner.
	Fields auth.Fields `json:"fields,omitempty"`
	// contains filtered or unexported fields
}

SignInRequest is the input of sign-in: a number, its code, and the auth_user columns of the account the code creates, better-auth's additional fields in the phone verify body.

func (SignInRequest) RateIdentifier

func (r SignInRequest) RateIdentifier() string

RateIdentifier counts guesses per normalized number, as CodeRequest does.

func (*SignInRequest) UnmarshalJSON

func (r *SignInRequest) UnmarshalJSON(data []byte) error

UnmarshalJSON keeps the top-level keys that are not the request's own, so sign-up fields may sit beside phone and code; auth.Auth.MergeFields then refuses any that is not a declared column.

type SignInResult

type SignInResult struct {
	User auth.UserView `json:"user"`
	// Created is true when the code created the account.
	Created bool `json:"created"`
	// SecondFactorRequired is true when the session is pending a second
	// factor.
	SecondFactorRequired bool       `json:"secondFactorRequired"`
	Token                auth.Token `json:"-"`
}

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

type Verifier

type Verifier interface {
	Send(ctx context.Context, number string) error
	Check(ctx context.Context, number, code string) error
}

Verifier is a provider's own verification service, such as Twilio Verify: it makes the code, texts it and checks it, so the library never sees the code. Send starts a verification for the number; Check reports a wrong, expired or exhausted code as ErrCodeInvalid and any other failure as its own error, which fails the operation rather than refusing the user.

With a Verifier (WithVerifier) the plugin issues no code of its own for verify, sign-in and change: the provider's attempt counter and lifetime apply instead of WithMaxAttempts and WithCodeTTL. The rate limits on sending still apply, because every Send costs money. Other messages (a reset link, a sign-in link) still go through the SMSSender.

Jump to

Keyboard shortcuts

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