anonymous

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

README

github.com/ubgo/auth/plugin/anonymous

Gives a visitor an account before they have said who they are: a guest session for a cart, a trial or a draft. The moment the guest signs up or signs in with any method, the app's hook moves their data to the real account and the guest row is deleted.

Install

go get github.com/ubgo/auth

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

Wire it

From examples/guest-sessions:

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

A guest is an ordinary auth_user row with no identifier and the plugin's is_anonymous column set, so every app table that references a user works for a guest unchanged. The plugin is a sign-in method by itself; an app with guests and nothing else passes auth.New.

auth.New never fails for this plugin's sake: it has no required option.

Options

Option What it does Default
OnLink(fn) The app's hook when a guest signs up or in: move the guest's rows to the account, inside the sign-in's transaction (LinkFunc) nothing moves; the guest is deleted (or kept)
KeepUsers() Keep the guest row after linking, and leave the delete route out the guest is deleted once linked
WithName(fn) How a guest's display name is made DefaultName (Guest) for everyone
SweepStale() a.Sweep deletes guests older than the retention whose every session has expired off: a sweep deletes no guest
WithSignInLimit(n, window) Rate rule of sign-in per IP 10 per hour (signInIPLimit, signInIPWindow)

Routes and Go API

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

Route Go method Requirement Rate limit
POST /anonymous/sign-in (optional remember) SignIn(ctx) returns SignInResult{User, Token} Public 10 per hour per IP (signInIPLimit, signInIPWindow)
POST /anonymous/delete Delete(ctx) SignedIn none
  • sign-in creates a guest and starts a session. A caller who is already a guest is refused with anonymous.already, so a page that reloads cannot pile up guest rows. A signed-in real user who asks gets a guest session and their cookie is replaced; nothing links. remember: false makes the session transient, as for a password sign-in.
  • delete removes the guest's own account and ends the session; a real account answers anonymous.not_anonymous. Not registered under KeepUsers().
  • IsAnonymous(ctx, userID) reports whether a user is a guest, for the app's own checks.

Linking

Every sign-in method ends by creating a session, so the link is a data hook on session creation: the request's signed-in user is the guest, the new session's user is the account. It fires for a password sign-up or sign-in, a magic link, an email or SMS code, a social provider and a passkey alike, and for a method added later. The hook does not fire for a session of the same user, for an impersonation, or when the request carries no guest.

Inside the hook, in the sign-in's own transaction: the app's OnLink runs with the guest and the account, then the guest is deleted (with KeepUsers(), kept). An OnLink error fails the sign-in and leaves the guest as it was. A guest who adds and verifies an address or number of their own stops being a guest: the flag clears, and a later sign-in to another account no longer links them away.

Tables and columns

The plugin owns no table. It adds one column to auth_user:

Column Type Meaning
is_anonymous bool, default false true for a guest; false for everyone else, including a guest who later verified an identifier

Read it in a composite user through anonymous.AnonymousColumns and anonymous.Bindings, or filter guests out of your own queries with anonymous.IsAnonymous.

Errors

Error Code HTTP When
ErrAlreadyAnonymous anonymous.already 409 a guest asks for another guest session
ErrNotAnonymous anonymous.not_anonymous 403 a real account calls delete
auth.ErrRateLimited auth.rate_limited 429 the sign-in limit above

Security notes

Property Pinned by
A guest is linked and deleted on sign-up from the guest session; a second guest sign-in is refused; a real user's guest links nothing integration/TestAnonymous_GuestSignsUpAndIsLinked
Password sign-in, a magic link and an email code all link; a sign-in without a guest session links nothing integration/TestAnonymous_LinksOnEverySignInPath
An OnLink error rolls the sign-in back and keeps the guest integration/TestAnonymous_LinkErrorFailsTheSignIn
KeepUsers keeps the row and hides the route integration/TestAnonymous_KeepUsers
A guest deletes only their own account integration/TestAnonymous_DeleteOwnAccount
A verified identifier ends guest status integration/TestAnonymous_VerifiedIdentifierEndsGuestStatus
The sweep deletes only old guests with no live session, and only under SweepStale integration/TestAnonymous_SweepStale, integration/TestAnonymous_SweepOffByDefault
A guest another instance's sweep deleted first is skipped, not counted; WithName names the guest integration/TestAnonymous_SweepSkipsAGuestAnotherSweepDeleted
Guest creation is limited per IP integration/TestAnonymous_RateAndRemember
Every database failure fails the operation integration/TestIdentFaults_Anonymous

Limits:

  • A guest session is a cookie: whoever holds the device holds the cart. Nothing in it is worth more than that until the person signs in.
  • Deleting a guest deletes the library's rows for it, and your User.BeforeDelete hook (auth.WithDataHooks) is where the app's rows that hold the guest's id go; there is no cascade. Move what matters in OnLink first; use KeepUsers() when guest rows have value of their own.

Example

Documentation

Overview

Package anonymous gives a visitor an account before they have told the app who they are: a guest session for a cart, a trial or a draft, and a link to the real account the moment they sign up or sign in.

A guest is an ordinary auth_user row with no identifier and the plugin's is_anonymous column set, so every app table that references a user works for a guest unchanged. The link runs inside the sign-in's own transaction through a data hook on session creation, which is the one step every sign-in method ends with — password, code, link, provider, passkey — so no method has to know this plugin exists, and a method added later links too.

Index

Constants

View Source
const (
	EndpointSignIn auth.EndpointID = "anonymous.sign-in"
	EndpointDelete auth.EndpointID = "anonymous.delete"
)

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

View Source
const ColIsAnonymous = "is_anonymous"

ColIsAnonymous is the column this plugin adds to auth_user. It is true for a guest and false for everyone else, including a guest who later proved an identifier of their own.

View Source
const DefaultName = "Guest"

DefaultName is the display name a guest gets unless WithName says otherwise. A fixed word rather than a random one, so a guest can be told apart from a real user in any list that shows names.

View Source
const PluginName = "anonymous"

PluginName is the plugin's permanent identity: route prefix and migration owner.

Variables

View Source
var (
	// ErrAlreadyAnonymous refuses a guest who asks for another guest
	// session: the first one already holds their data, and piling up guest
	// rows per reload is the failure the refusal prevents.
	ErrAlreadyAnonymous = auth.NewError("anonymous.already", http.StatusConflict, "You already have a guest session.")
	// ErrNotAnonymous refuses the delete route for a real account, which
	// has sign-in methods and belongs with the app's own account deletion.
	ErrNotAnonymous = auth.NewError("anonymous.not_anonymous", http.StatusForbidden, "This is not a guest account.")
)

Errors this plugin returns. Both reach the user with stable codes.

IsAnonymous is the typed column on auth_user, for an app that filters guests out of its own queries.

Functions

func Bindings

func Bindings(c *AnonymousColumns) []sqlb.Binding

Bindings binds AnonymousColumns for a query on auth_user.

Types

type AnonymousColumns

type AnonymousColumns struct {
	IsAnonymous bool
}

AnonymousColumns is this plugin's flat part of a composite user: the column is on auth_user, so the field is flat (ADR-0001). The name stutters on purpose: Go names an embedded field after its type, and two plugins both exporting Columns could not be embedded together.

type LinkFunc

type LinkFunc func(ctx context.Context, tx sqlb.DBTX, guest, user auth.User) error

LinkFunc is the app's hook when a guest becomes, or signs in to, a real account: move the guest's rows to user, inside tx, so the move and the sign-in commit or roll back together. guest is deleted right after it returns unless KeepUsers is set; an error fails the sign-in and leaves the guest as it was.

type Option

type Option func(*Plugin)

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

func KeepUsers

func KeepUsers() Option

KeepUsers leaves the guest row in place after it is linked and leaves the delete route out, for an app that keeps guest rows for its own records. Without it the guest is deleted once linked, so no orphan account remains.

func OnLink(fn LinkFunc) Option

OnLink sets the app's hook for moving a guest's data to the account they signed up or in with; see LinkFunc. Without it the guest is simply deleted (or kept, with KeepUsers) and nothing moves.

func SweepStale

func SweepStale() Option

SweepStale makes a.Sweep delete guests who never linked and whose every session has expired, once they are older than the sweep's retention. Off by default because deleting a user cascades to the app's rows that reference it; turn it on when guest data has no value after the session ends.

func WithName

func WithName(fn func() string) Option

WithName sets how a guest's display name is made, for an app that wants "Visitor 4812" or a random animal; the default is DefaultName for everyone.

func WithSignInLimit

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

WithSignInLimit replaces the default of 10 guest sign-ins per IP per hour.

type Plugin

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

Plugin is the anonymous sign-in method. Build it with New: the zero value has no operations.

func New

func New(opts ...Option) *Plugin

New builds the plugin. With no options a guest is named DefaultName, is deleted when linked, and may delete their own account.

func (*Plugin) Columns

func (p *Plugin) Columns() []auth.ColumnExtension

Columns adds is_anonymous to auth_user, default false.

func (*Plugin) DataHooks

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

DataHooks link a guest to the account they sign in to, and turn a guest into a regular user once they prove an identifier of their own.

The link runs on session creation because every sign-in method ends there. The request's signed-in user is the guest (auth.CurrentUser reads the request's own session, which the pipeline resolved before the operation ran); the new session's user is the account. A session for the same user, an impersonation, or a request with no guest changes nothing.

func (*Plugin) Delete

func (p *Plugin) Delete(ctx context.Context) error

Delete removes the signed-in guest's account and ends the session. A real account is refused with ErrNotAnonymous.

func (*Plugin) Init

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

Init captures the core handle the hooks call.

func (*Plugin) IsAnonymous

func (p *Plugin) IsAnonymous(ctx context.Context, userID string) (bool, error)

IsAnonymous reports whether userID is a guest.

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 sign-in, and delete unless KeepUsers 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 bounds guest creation per IP.

func (*Plugin) SignIn

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

SignIn creates a guest and starts a session for them. A caller who is already a guest is refused with ErrAlreadyAnonymous.

func (*Plugin) SignsIn

func (p *Plugin) SignsIn() bool

SignsIn reports that a guest session is a way in, so an app whose only other plugins add identifiers still passes the core's sign-in check. Such an app has guests and nothing else, which is a real shape for a demo.

func (*Plugin) Sweep

func (p *Plugin) Sweep(ctx context.Context, cutoff time.Time) (int64, error)

Sweep deletes guests created before cutoff who have no live session, when SweepStale is set; otherwise it deletes nothing. Each is deleted through the core, one by one, so the app's User BeforeDelete hooks run for every guest as they do for a delete by hand.

func (*Plugin) Validate

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

Validate refuses a sign-in limit that could not count.

type SignInRequest

type SignInRequest struct {
	// Remember is the "remember me" box, as on a password sign-in: nil or
	// true keeps the guest session past the browser, false makes it
	// transient.
	Remember *bool `json:"remember,omitempty"`
}

SignInRequest is the input of sign-in. It has no identifier: a guest is nobody in particular.

type SignInResult

type SignInResult struct {
	User  auth.UserView `json:"user"`
	Token auth.Token    `json:"-"`
}

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

Jump to

Keyboard shortcuts

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