username

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: 8 Imported by: 0

README

github.com/ubgo/auth/plugin/username

Adds handles (usernames) to accounts: a user claims or renames a handle, anyone can ask whether a handle is free, reserved names such as admin can never be claimed, and a handle can optionally sign in with the password.

Install

go get github.com/ubgo/auth

The package is part of the core module, so it adds no dependency.

Wire it

Adapted from the plugin list in examples/poc:

a, err := auth.New(
	auth.WithDB(db, sqlb.SQLiteDialect{}),
	auth.WithBaseURL(baseURL),
	auth.WithPlugins(
		email.New(email.Login(), email.Recover(), email.WithMailer(m)),
		username.New(username.Login(), username.Reserved("khanakia", "ubgo")),
		password.New(),
	),
)

A handle is not a sign-in method on its own: pair it with password (or another method). A handle cannot receive messages, so it can never recover an account. Username plus password alone fails in auth.New with password: no identifier can recover an account; add one with Recover(), for example email.New(email.Recover()), or pass password.WithoutRecovery().

The plugin has no Validate refusals of its own.

Options

Option What it does Default
Login() The handle can sign in (e.g. with password) off
ScopedByTenant() Handles unique per tenant (auth.WithScope) global
Length(min, max) Replaces the length bounds; auth.New refuses min below 1 or max below min (ErrBadLength) MinLength = 2, MaxLength = 39
WithValidator(fn) Replaces the character rule (ValidateChars) for the normalized handle; the length bounds still apply. Keep @ and a leading + out, or one sign-in field can no longer tell a handle from an address or a number ValidateChars
WithNormalizer(fn) Replaces Normalize (trim and lowercase); the reserved list is still matched on the trimmed, lowercased form Normalize
Immutable() set refuses a rename once the user has a handle (username.immutable); the first handle can still be set off
WithoutAvailability() Leaves the public available route out route on
WithAvailableLimit(n, window) Replaces the available route's own per-IP limit; auth.New refuses a limit below 1 or a window of zero (ErrBadAvailableLimit) DefaultAvailableLimit per DefaultAvailableWindow
DisplayValidator(fn) A rule for the display form a client sends as displayUsername (better-auth's displayUsernameValidator); an error refuses the call with username.display_invalid no extra rule
DisplayNormalizer(fn) Rewrites the display form before it is stored (better-auth's displayUsernameNormalization) kept as sent
ValidateDisplayAfterNormalization() DisplayValidator sees the rewritten form instead of the one sent (better-auth's validationOrder.displayUsername: "post-normalization") before normalization
Reserved(names...) Adds names to the reserved set, on top of DefaultReserved; names are trimmed and lowercased DefaultReserved (21 names: admin, root, support, settings, api, login, …)

A handle is always unique over every row, verified or not, because a handle has no verification. It is stored verified. Validate allows MinLength (2) to MaxLength (39) characters: lowercase letters, digits, - and _, starting with a letter. The leading letter keeps a handle from ever looking like a phone number, and the missing @ keeps it from looking like an address, so one sign-in field can tell the three apart.

Routes and Go API

The routes are under your mount prefix (for example /auth). The plugin declares no rate limits.

Route Go method Requirement Rate limit
POST /username/set with username and optional displayUsername Set(ctx, handle), SetWithDisplay(ctx, handle, display) return Result{Username} SignedIn none
GET /username/available?username=… Available(ctx, handle) returns bool Public 30 per minute per IP (WithAvailableLimit)
(no route) Claim(ctx, userID, handle) and ClaimWithDisplay(ctx, userID, handle, display): the same rules as set, from inside another plugin's operation, such as a password sign-up that takes a username in the same body (Claim); ClaimWithDisplay is what makes this plugin the password plugin's UsernameClaimer the caller's the caller's
  • The display form is what Data.Display shows: the handle as typed, or the displayUsername the client sent (on set, or as displayUsername on a password sign-up beside username). It goes through DisplayNormalizer and DisplayValidator, and one rule always holds: it must normalize to the same handle, so "AdaLovelace" can be shown for adalovelace but "admin" cannot be shown for joe. better-auth checks nothing by default; a display free of the handle would get around the reserved list and the uniqueness the handle exists for (DisplayForm).
  • set claims the new handle first, then deletes the old one. A taken handle fails before the old one is released, and after a rename the old one is free for anyone.
  • available answers false for a reserved, malformed or taken handle. The answer can be stale by the time set runs; the unique index is the authority.
  • IsReserved(handle) checks the reserved set in Go.

Tables and columns

The plugin owns no table.

  • Handles: each handle is a row of the core auth_identity table with kind = 'username': value holds the normalized form, value_raw what the user typed. The core's 0000_identity_unique_username_all migration (…_all_scoped under ScopedByTenant()) adds the unique index.
  • Queries: the plugin reads auth_identity under the alias uni (TableAliasIdentity).
    • username.UserJoin() with username.UserBindings(&u.Handle) fills a nested username.Data (Handle, Display) in a composite user query.
    • username.Of(a, userID, &d) builds the standalone lookup.
    • username.ByHandleQuery(a, handle, &u) resolves a user from a handle.

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

Errors

Error Code HTTP When
username.ErrReserved username.reserved 409 set of a reserved handle, however the identity is created (a data hook)
username.ErrDisplayInvalid username.display_invalid 400 a displayUsername that DisplayValidator refused or that does not normalize to the handle
username.ErrImmutable username.immutable 409 set of a new handle under Immutable() when the user already has one
username.ErrBadLength (plain error, from auth.New) – Length with min below 1 or max below min
auth.ErrIdentityTaken auth.identity_taken 409 set of a handle another user has
auth.ErrIdentifierInvalid auth.identifier_invalid 400 set of a malformed handle
auth.ErrPluginNotRegistered (plain error) – Of on an *auth.Auth without this plugin

Security notes

Property Pinned by
available stops one client at its own limit and lets it in again after the window integration/TestR23_UsernameAvailableHasItsOwnLimit, plugin/username/TestAvailableLimit
Reserved names are refused on every path, whatever their case; the list is additive integration/TestUsername_SetAvailableReserved, plugin/username/TestIsReserved_CoversRouteCollisionsAndIsAdditive, TestReserved_FoldsLikeTheNormalizer
A handle is unique over every row, not only verified ones plugin/username/TestKind_IsUniqueOverEveryRow
Lookup normalizes in Go and compares exactly, never with SQL lower() plugin/username/TestByHandleQuery_ComparesTheNormalFormExactly
Claim applies the reserved, taken and malformed rules and replaces an existing handle; under Immutable() a second claim is refused integration/TestIdentUsername_Claim, integration/TestIdentUsername_ClaimImmutable
Every database failure fails the operation integration/TestIdentFaults_Username

Limits:

  • available is public and has its own per-IP limit, counted apart from every other route, so one client cannot walk a dictionary of handles. Change it with WithAvailableLimit, replace it with an app rule for EndpointAvailable, or leave the route out with WithoutAvailability().
  • DefaultReserved is a minimum; add every route name of your app with Reserved.

Example

Documentation

Overview

Package username adds handle-based identity to the auth core.

Like the phone plugin it stores nothing of its own: a username is an identifier and lives in the core's shared auth_identity table. What this package adds is the policy a handle needs and an email address does not — reserved names, and the rule about what happens to a handle after a rename.

Index

Constants

View Source
const (
	EndpointSet       auth.EndpointID = "username.set"
	EndpointAvailable auth.EndpointID = "username.available"
)

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

View Source
const (
	//ds:def id=minlength-x3pjevmb owner=@khanakia stability=stable
	MinLength = 2
	//ds:def id=maxlength-xnr4ndys owner=@khanakia stability=stable
	MaxLength = 39
)

Handle length bounds. Short handles invite squatting and long ones break layouts; both ends are product choices made explicit.

View Source
const (
	//ds:def id=username-availablelimit-6tq2nw8k owner=@khanakia stability=stable
	DefaultAvailableLimit = 30
	//ds:def id=username-availablewindow-3hv7mc5p owner=@khanakia stability=stable
	DefaultAvailableWindow = time.Minute
)

The available route's default rate limit, per client IP. The route is public and answers "taken" or "free" for any handle, so without a limit of its own one client could walk a dictionary and list every account; the numbers leave room for a sign-up form that checks as the user types.

View Source
const KindUsername auth.IdentifierKindName = "username"

KindUsername 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 = "username"

PluginName is the stable identity of this plugin.

View Source
const TableAliasIdentity = "uni"

TableAliasIdentity is this plugin's alias for the shared identity table. Distinct from every other plugin's alias because a composite user query joins that table once per identifier kind.

Variables

View Source
var (
	ErrReserved = auth.NewError("username.reserved", http.StatusConflict, "This username is not available.")
	// ErrImmutable refuses a rename under Immutable(): the handle was set
	// once and the product decided it stays.
	ErrImmutable = auth.NewError("username.immutable", http.StatusConflict, "Your username cannot be changed.")
	// ErrBadLength is a configuration error from auth.New: Length was given
	// bounds nobody could satisfy.
	ErrBadLength = errors.New("username: Length needs 1 <= min <= max")
	// ErrBadAvailableLimit is a configuration error from auth.New:
	// WithAvailableLimit was given a limit below one or a window of zero.
	ErrBadAvailableLimit = errors.New("username: WithAvailableLimit needs a limit of one or more and a positive window")
	// ErrDisplayInvalid refuses a display form (Request.DisplayUsername)
	// that DisplayValidator rejected, or that does not normalize to the
	// handle it is shown for; better-auth's INVALID_DISPLAY_USERNAME.
	//ds:def id=username-errdisplayinvalid-4kq8wz2m owner=@khanakia stability=stable
	ErrDisplayInvalid = auth.NewError("username.display_invalid", http.StatusBadRequest, "This display username is not valid for the username.")
)

ErrReserved refuses a handle on the reserved list. It reads "not available" rather than "reserved" so it gives nothing away about the list.

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

View Source
var DefaultReserved = []string{
	"admin", "administrator", "root", "support", "help", "about", "api",
	"auth", "login", "logout", "signup", "register", "settings", "account",
	"billing", "security", "system", "static", "assets", "www", "mail",
}

DefaultReserved is the baseline set of handles a deployment must not hand out.

Why this list exists at all: the moment handles appear in URLs, every handle competes with every route. A user who claims "settings" makes /settings ambiguous, and a user who claims "admin" or "support" can impersonate the operator in any context where a handle is shown without qualification. This is the minimum; a deployment adds its own route names.

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

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

Functions

func ByHandleQuery

func ByHandleQuery(a *auth.Auth, rawHandle string, dst *auth.User) (sqlb.Query, error)

ByHandleQuery resolves a user from a handle.

The handle is normalized in Go and compared exactly. Handles are stored in the same normal form, so the exact comparison finds them and uses the plain unique index; a SQL lower() would add a second folding rule that differs between engines. See auth.Auth.FindIdentity.

func Normalize

func Normalize(raw string) (string, error)

Normalize lowercases and trims a handle. The typed spelling survives in Identity.ValueRaw for display.

The error is always nil; the signature is the auth.IdentifierKind contract. Code in this package calls fold directly so it has no error path to ignore.

func Of

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

Of builds the standalone lookup for one user's handle. It fails with auth.ErrPluginNotRegistered when a is not wired with this plugin.

func UserBindings

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

UserBindings binds this plugin's columns to dst, flattening the LEFT join's nulls to empty strings.

func UserJoin

func UserJoin() sqlb.Join

UserJoin is the package-level form for an application assembling a composite destination.

LEFT because a user need not have a handle, and constrained by kind because the identity table holds every identifier kind in one place.

func Validate

func Validate(v string) error

Validate checks length and characters: lowercase letters, digits, "-" and "_", starting with a letter. The leading letter keeps a handle from ever looking like a phone number, and the missing "@" from looking like an address, so one sign-in field can tell the three apart.

func ValidateChars

func ValidateChars(v string) error

ValidateChars is the default character rule of Validate, without the length bounds: lowercase letters, digits, "-" and "_", starting with a letter.

Types

type Availability

type Availability struct {
	Available bool `json:"available"`
}

Availability answers whether a handle can be claimed. Handles are public by nature, so answering is not an enumeration leak.

type Data

type Data struct {
	Handle  string
	Display string
}

Data is this plugin's contribution to a user, shaped as a nested edge.

Handle is the normalized lowercase form used for lookup and uniqueness. Display is what the user typed, so a handle shown back to them keeps its original casing. Both are empty when the user has no handle.

Nested rather than flattened for the same reason as the phone plugin's: a handle lives in auth_identity, not in auth_user, and the Go shape follows the schema so a reader can see the join boundary.

func (*Data) Exists

func (d *Data) Exists() bool

Exists reports whether the user has claimed a handle. The LEFT join yields a zero-valued Data rather than an absent one, so absence has to be asked for.

type Option

type Option func(*Plugin)

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

func DisplayNormalizer

func DisplayNormalizer(fn func(display string) string) Option

DisplayNormalizer rewrites the display form before it is stored, better-auth's displayUsernameNormalization (say, collapsing spaces or NFC-normalizing). By default the display form is kept as sent. The rewritten form must still normalize to the handle.

func DisplayValidator

func DisplayValidator(fn func(display string) error) Option

DisplayValidator adds a rule for the display form a client sends beside the handle (Request.DisplayUsername), better-auth's displayUsernameValidator: an error refuses the request with ErrDisplayInvalid. It runs on the display form as sent, or on its DisplayNormalizer result under ValidateDisplayAfterNormalization. The display form must also normalize to the handle whatever this rule says; see DisplayForm.

func Immutable

func Immutable() Option

Immutable makes a handle permanent: set refuses with ErrImmutable once the user has one. Pick it when handles appear in URLs other people have saved.

func Length

func Length(min, max int) Option

Length replaces MinLength and MaxLength, the bounds on a handle's length after normalization. auth.New refuses min below 1 or max below min.

func Login

func Login() Option

Login allows handles in sign-in, beside or instead of an email address.

func Reserved

func Reserved(names ...string) Option

Reserved adds handles to the reserved set, on top of DefaultReserved.

Additive rather than replacing, because a deployment adding its own route names almost never intends to also release "admin" — and if the option replaced the defaults, that release would be silent.

func ScopedByTenant

func ScopedByTenant() Option

ScopedByTenant makes handles unique per tenant, for an app where each tenant has its own namespace; see auth.WithScope.

func ValidateDisplayAfterNormalization

func ValidateDisplayAfterNormalization() Option

ValidateDisplayAfterNormalization runs DisplayValidator on the DisplayNormalizer result instead of the form as sent, better-auth's validationOrder.displayUsername "post-normalization".

func WithAvailableLimit

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

WithAvailableLimit replaces the available route's default rate limit (DefaultAvailableLimit per DefaultAvailableWindow), counted per client IP and apart from every other route's. Lower it for a product whose handles are private; raise it for a form that checks on every keystroke. An app's auth.WithRateRules for EndpointAvailable still replaces it.

func WithNormalizer

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

WithNormalizer replaces Normalize (trim and lowercase), for an app that folds look-alike characters or keeps case. The reserved list is still matched on the trimmed, lowercased form.

func WithValidator

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

WithValidator replaces the character rule (ValidateChars) for the normalized handle; the length bounds still apply. A rule that allows "@" or a leading "+" breaks the one sign-in field that tells a handle from an address or a number, so keep those out.

func WithoutAvailability

func WithoutAvailability() Option

WithoutAvailability leaves the public available route out, for an app that would rather not let anyone probe which handles exist.

type Plugin

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

Plugin adds username identity. Build it with New: the zero value has no kind, no reserved list and no operations.

func New

func New(opts ...Option) *Plugin

New builds the plugin. A handle is always unique over every row: it has no verification, so "unique among verified" would mean not unique at all.

func (*Plugin) Available

func (p *Plugin) Available(ctx context.Context, handle string) (bool, error)

Available reports whether a handle can be claimed now. The answer can be stale by the time Set runs; the unique index is the authority.

func (*Plugin) Claim

func (p *Plugin) Claim(ctx context.Context, userID, handle string) error

Claim gives userID the handle as part of a larger operation, such as a password sign-up that takes a username in the same body. It applies every rule Set applies — normalization, length, characters, the reserved list, uniqueness — and refuses a user who already has a handle with ErrImmutable under Immutable(), or replaces it otherwise.

It is not an operation of its own: the caller is one (the password plugin's sign-up), already inside the pipeline and its transaction, so the handle and the account commit or roll back together. From outside an operation use Set.

func (*Plugin) ClaimWithDisplay

func (p *Plugin) ClaimWithDisplay(ctx context.Context, userID, handle, display string) error

ClaimWithDisplay is Claim with a display form other than the handle as typed (a sign-up body's displayUsername); see DisplayForm.

func (*Plugin) DataHooks

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

DataHooks refuse a reserved handle however the identity is created.

func (*Plugin) DisplayForm

func (p *Plugin) DisplayForm(handle, display string) (string, error)

DisplayForm returns the form stored and shown for handle (Data.Display): handle as typed when display is empty, otherwise display after DisplayNormalizer and DisplayValidator.

One rule is not configurable: the display form must normalize to the same handle. Data.Display is shown wherever the handle is, so a display form free of the handle would let "joe" be shown as "admin", around the reserved list and the uniqueness the handle exists for. better-auth checks nothing by default; DisplayValidator is the place for a stricter rule.

func (*Plugin) Identifiers

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

Identifiers registers the username kind with the core, which from then on owns its uniqueness check.

func (*Plugin) Init

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

Init captures the core handle Of builds its query with.

func (*Plugin) IsReserved

func (p *Plugin) IsReserved(handle string) bool

IsReserved reports whether a handle is withheld. The argument is normalized first so a check against "Admin" behaves like a check against "admin".

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 set, and available unless WithoutAvailability left it out.

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 gives the available route its own ByIP limit, so probing which handles exist is bounded however the app sets its blanket limit. Nothing when WithoutAvailability left the route out.

func (*Plugin) Set

func (p *Plugin) Set(ctx context.Context, handle string) (Result, error)

Set claims a handle for the signed-in user, replacing the old one.

func (*Plugin) SetWithDisplay

func (p *Plugin) SetWithDisplay(ctx context.Context, handle, display string) (Result, error)

SetWithDisplay is Set with a display form other than the handle as typed; see DisplayForm.

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

func (p *Plugin) UserJoin() sqlb.Join

UserJoin brings the handle into a composite user query; see the package-level UserJoin.

func (*Plugin) Validate

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

Validate refuses length bounds nobody could satisfy, so a typo in Length fails auth.New rather than every set.

type Request

type Request struct {
	Username        string `json:"username"`
	DisplayUsername string `json:"displayUsername,omitempty"`
}

Request names a handle as the user typed it; the display form keeps its casing, the stored form is normalized. DisplayUsername, optional, is the form to show instead of the one typed (better-auth's displayUsername), such as "AdaLovelace" for the handle "adalovelace"; see DisplayForm.

type Result

type Result struct {
	Username string `json:"username"`
}

Result is the handle now set, in its normalized form.

Jump to

Keyboard shortcuts

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