authcli

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

README

github.com/ubgo/auth/authcli

Gives your app's own binary an auth command for operators: apply migrations, preview them, print them as SQL, list what no plugin owns any more, delete expired rows, run the plugins' scheduled jobs, and make the first admin.

Install

go get github.com/ubgo/auth

It is part of the core module and uses only the standard library. It is a package rather than a binary because only your app knows its plugin list, and the plugin list decides the migrations.

Wire it

From examples/cli: route the first argument to authcli.Main and exit with the code it returns.

func main() {
	// ... open db, build a with auth.New ...
	ctx := context.Background()
	if len(os.Args) > 1 && os.Args[1] == "auth" {
		code := authcli.Main(ctx, a, os.Args[2:], os.Stdout, os.Stderr)
		_ = a.Close() // os.Exit skips the deferred calls
		os.Exit(code)
	}
	// ... serve ...
}

args excludes the program name and the word the app routed on. Output meant for a pipe (the SQL, the status table) goes to stdout; usage and errors go to stderr. Main reads the process's standard input when a command asks for it (admin create --password-stdin); MainWithStdin takes another reader, for an app that embeds the command line behind its own and for tests.

Options

None. Main takes the *auth.Auth, the arguments and the two writers; MainWithStdin adds the reader.

Commands

Command What it does Writes to the database
auth migrate Applies every pending migration and prints applied <owner>/<name> for each, or nothing to apply. yes
auth migrate --dry-run Lists the pending and changed migrations without applying anything, then N pending, N changed, N total. no
auth migrate status Lists every migration as applied, pending or CHANGED, with its owner and name, then the same totals. no
auth migrate sql Prints every migration as SQL for this engine (-- <owner> <name> then the statements). It needs no database. no
auth generate The same as auth migrate sql, under better-auth's name, for a migration tool the app already runs.
auth migrate orphans Lists the tables, columns and owners in the database that no registered plugin claims now (<kind>\t<name>), then N orphan(s). It deletes nothing; the operator decides. no
auth migrate check Compares the database with the code: pending and changed migrations from the ledger, and the tables and columns the migrations create against the catalogue (a.CheckSchema). Prints pending\t<owner/name>, CHANGED\t<owner/name>, missing table\t<name> and missing column\t<table.column> lines, then fails with auth.ErrSchemaBehind and exit 1 when anything is listed, or prints schema ok. For a deploy script: myapp auth migrate check && myapp serve. no
auth sweep [--retention 168h] Runs a.Sweep: deletes expired sessions, verification tokens expired or spent more than --retention ago, finished rate-limit windows, delivery outbox rows past the retention and each plugin's expired rows. It prints <owner.table>\t<count> sorted by name; with auth.WithDeliveryOutbox it then runs a.SendOutbox and adds outbox.sent and outbox.failed lines. The default retention is auth.DefaultSweepRetention (7 days). yes
auth maintain [--dry-run] Runs a.Maintain: every registered plugin that implements auth.Maintainer, in registration order, such as plugin/stale's mail to dormant accounts and, if turned on, their deletion. It prints <plugin>.<counter>\t<count> sorted by name, and nothing when no plugin has a job. --dry-run passes auth.MaintainOptions{DryRun: true}: the same counters, nothing sent, written or deleted. A plugin that fails stops the run; the counters it had are printed, the error goes to stderr and the exit code is 1. yes, without --dry-run
auth info [--json] Prints how this instance is wired, for a support request or a deployment check: the library version from the binary's build information ((devel) or unknown for a work-tree build), engine, base URL, number of trusted origins, fresh window, session lifetimes, plugins, identifier kinds, migrations, disabled operations, and every route with its method, requirement and id. --json prints the same as indented JSON (authcli.Info; lifetimes are strings such as "720h0m0s" under camelCase names, and the document decodes back into authcli.Info). It touches no database and holds no secret or user data. no
auth secret [--value] Prints AUTH_SECRET=<value>: 32 random bytes from crypto/rand, base64-encoded, a value auth.WithSecret accepts; --value prints the value alone, for a pipe into a vault. Touches no database; a failing random source is exit 1, never a weak value.
auth client ts --out <dir> Writes the typed TypeScript client of this app's routes into <dir> (created when missing) and lists the files: authhttp.TypeScriptClient over the app's own *auth.Auth, so its custom plugins, their input and output types and their error codes are typed exactly. Touches no database. A name the generator cannot give (two operations one method, a plugin named a TypeScript keyword) is exit 1 with the reason. Guide: your own plugins in the TypeScript client; example: ts-client-custom. no
auth admin grant --email x [--json] [--dry-run] Makes the owner of a verified address an admin through the admin plugin's Grant, found by interface (AdminGranter); the address is resolved through the identifier plugin that owns addresses, verified only, because admin rights for whoever typed an address at sign-up would be a takeover. Prints <email> (<userId>) is an admin, or the AdminResult as JSON. --dry-run resolves the user and changes nothing. Granting an admin again succeeds and changes nothing. yes
auth admin create --email x [--name y] [--password-stdin] [--json] [--dry-run] Creates a user with a verified address (the operator vouches for it), the password read from stdin when --password-stdin is given, and admin rights, in one transaction: a refused password or a taken address leaves no user. There is no --password flag, because argv is visible to other processes and lands in shell history; the password is the whole of stdin minus one trailing line break, as docker login --password-stdin reads it. Without a password the user exists and signs in another way (a magic link, OAuth, a reset). --dry-run checks that the address is free and changes nothing. yes
-h on migrate, sweep, maintain, info or admin Prints the usage. no

The command names are exported as CmdMigrate, CmdSQL, CmdStatus, CmdOrphans, CmdSweep, CmdMaintain, CmdInfo, CmdClient with CmdClientTS, and CmdAdmin with AdminGrant and AdminCreate.

Plugins found by interface

auth admin imports no plugin. It finds, through auth.PluginOf, the registered plugin with each method it needs, so an app registers nothing extra and the core module stays free of the password module: AdminGranter (Grant(ctx, userID), which *admin.Plugin has), AddressSetter (Identifiers() for the kind to look up and SetFor(ctx, userID, address, verified), which *email.Plugin has) and PasswordSetter (SetPassword(ctx, userID, password), which *password.Plugin has; needed only under --password-stdin). A missing one fails with exit 1 and names the plugin to register (ErrNoAdminPlugin, ErrNoAddressPlugin, ErrNoPasswordPlugin). authcli.InfoOf(a) returns the Info that auth info prints, for an app that wants the same report on a status page.

Exit codes
Constant Value Meaning
ExitOK 0 Done, including -h.
ExitFailed 1 The command ran and failed; the reason is on stderr. This includes no database (auth: no database configured; pass auth.WithDB), an engine without DDL for migrate sql, a write to stdout that failed, and any CHANGED migration in status or --dry-run (auth.ErrChecksumMismatch), because migrate would refuse to run.
ExitUsage 2 The command never ran: no command, an unknown one, an unexpected argument, an unknown flag, a --retention that is not a duration, or a negative one, admin without --email, or --password-stdin with nothing on stdin (ErrNoPassword).

A script can therefore tell a typo (2) from a broken database (1).

Tables and columns

None of its own. migrate writes the core's ledger table auth_migrations (auth.TableNameMigrations) through a.Migrate, sweep deletes through a.Sweep, and maintain changes only what the plugins' jobs change, through a.Maintain.

Errors

Main returns an exit code, not an error; the error text goes to stderr prefixed auth migrate:, auth sweep:, auth maintain:, auth info:, auth admin grant: or auth admin create:. The admin command's own errors are ErrNoAdminPlugin, ErrNoAddressPlugin, ErrNoPasswordPlugin (exit 1) and ErrNoPassword (exit 2); auth.identity_not_found is a grant to an unknown or unverified address and auth.identity_taken a create of an address another account holds.

Security notes

Property Pinned by
--dry-run and status change nothing: after a dry run, auth_user does not exist. TestCLI_StatusDryRunMigrate (integration)
A migration edited after it was applied is reported as CHANGED and fails the command. TestCLI_StatusDryRunMigrate (integration)
Every database command fails with exit 1 and a reason when there is no database, never exit 0 with an empty report. TestMain_EveryDatabaseCommandFailsWithoutADatabase
A status that cannot be written fails instead of passing silently. TestCLI_ReportsWhatIsLeftAndWriteFailures (integration)
orphans only lists; it drops nothing. TestOrphans_ReportsWhatARemovedPluginLeft (integration)
migrate check exits 1 for an empty database, for a dropped column with every migration applied, and for a database error; 0 only when nothing is missing. TestCLI_MigrateCheck (integration)
auth info reads no database and reports no secret: the trusted origins and base URL are public addresses, the session block is configuration. by construction: InfoOf reads a.Config(), a.Plugins(), a.Operations() and a.Migrations() only
sweep deletes only what can no longer work. TestSweep_DeletesOnlyWhatCanNoLongerWork (integration)
maintain prints the counters sorted and passes --dry-run through; a failing job is exit 1 after the counters it had; an extra argument is a usage error; with no job it prints nothing. TestMain_MaintainPrintsSortedCountersAndPassesDryRun, TestMain_MaintainFailureIsExitFailedAfterTheCounters, TestMain_MaintainUsage, TestMain_MaintainWithNoMaintainerPrintsNothing
admin grant refuses an unverified address with exit 1 and grants nobody; --dry-run resolves the user and grants nobody. TestCLI_AdminGrant (integration)
admin create makes a user, a verified address, the stdin password and the admin grant in one transaction: a refused password or a taken address leaves no user, and the rehearsal creates nothing. TestCLI_AdminCreate (integration)
The password never comes from argv: there is no --password flag, a value after --password-stdin is an unexpected argument, and an empty stdin is a usage error. TestAdmin_PasswordNeverComesFromArgv, TestAdmin_UsageErrors
Every plugin the admin command works through is found by interface and its absence is named. TestAdmin_NamesTheMissingPlugin

Nothing runs the sweep or the maintenance jobs by itself (the library has no background workers), so schedule auth sweep and, when a plugin has a job, auth maintain from cron or your job runner. The sweep is idempotent and safe to run from several instances at once; a maintenance job is idempotent too, and --dry-run shows what it would do before it mails anyone.

Example

Documentation

Overview

Package authcli is the command line an app gives its operators for the auth schema: apply migrations, print them as SQL, or show what is pending.

It is a package, not a binary, because only the app knows its plugin list, and the plugin list decides the migrations. The app wires it into its own main or its own CLI framework:

if len(os.Args) > 1 && os.Args[1] == "auth" {
	os.Exit(authcli.Main(ctx, a, os.Args[2:], os.Stdout, os.Stderr))
}

Index

Constants

View Source
const (
	//ds:def id=cmdadmin-5rk2wq8n owner=@khanakia stability=stable
	CmdAdmin = "admin"
	//ds:def id=adminsubgrant-7mt4xk2p owner=@khanakia stability=stable
	AdminGrant = "grant"
	//ds:def id=adminsubcreate-9vq3nw6r owner=@khanakia stability=stable
	AdminCreate = "create"
)

The admin command and its subcommands: `auth admin grant` makes an existing user an admin, `auth admin create` creates one. They exist because the first admin cannot be made over HTTP (no admin exists to grant), and every app otherwise writes the same one-off job.

View Source
const (
	//ds:def id=exitok-gnrts3ej owner=@khanakia stability=stable
	ExitOK = 0
	//ds:def id=exitfailed-ssu2cn9e owner=@khanakia stability=stable
	ExitFailed = 1
	//ds:def id=exitusage-d43sp75x owner=@khanakia stability=stable
	ExitUsage = 2
)

Exit codes: the library-wide convention for command lines. ExitFailed means the command ran and failed (the reason is on stderr); ExitUsage means it never ran because the arguments were wrong, so a script can tell a typo from a broken database.

View Source
const (
	//ds:def id=cmdmigrate-mr367qab owner=@khanakia stability=stable
	CmdMigrate = "migrate"
	//ds:def id=cmdsql-y9ntekef owner=@khanakia stability=stable
	CmdSQL = "sql"
	//ds:def id=cmdstatus-cebt94q5 owner=@khanakia stability=stable
	CmdStatus = "status"
	//ds:def id=cmdorphans-ue59r4y3 owner=@khanakia stability=stable
	CmdOrphans = "orphans"
	// CmdCheck compares the live schema with the code: pending and changed
	// migrations, missing tables and columns. ExitFailed when anything is
	// behind, so a deploy script can gate on it.
	//ds:def id=cmdcheck-7hq3xw2n owner=@khanakia stability=stable
	CmdCheck = "check"
	//ds:def id=cmdsweep-nsszxzc2 owner=@khanakia stability=stable
	CmdSweep = "sweep"
	// CmdMaintain runs the plugins' scheduled jobs (auth.Maintain), such as
	// plugin/stale's mails to dormant accounts. Unlike sweep it can send mail and
	// delete accounts, so it is a command of its own and `--dry-run` shows what
	// it would do first.
	//ds:def id=cmdmaintain-4wn8qk3v owner=@khanakia stability=stable
	CmdMaintain = "maintain"
	//ds:def id=cmdinfo-7wkd3xqn owner=@khanakia stability=stable
	CmdInfo = "info"
	// CmdSecret prints a fresh random value for auth.EnvSecret, better-auth's
	// `secret` command. It touches no database.
	//ds:def id=cmdsecret-3wq8nk5v owner=@khanakia stability=stable
	CmdSecret = "secret"
	// CmdGenerate is `migrate sql` under better-auth's name (`generate`):
	// every migration as SQL, for a migration tool the app already runs.
	//ds:def id=cmdgenerate-8nd2xq4w owner=@khanakia stability=stable
	CmdGenerate = "generate"
)

Commands: the words Main routes on, after the "auth" word the app routed on. CmdSQL, CmdStatus and CmdOrphans are subcommands of CmdMigrate.

View Source
const (
	CmdClient   = "client"
	CmdClientTS = "ts"
)

CmdClient writes a client library for the app's HTTP API; CmdClientTS is its one language so far, TypeScript (authhttp.TypeScriptClient). It reads the app's own operation list, so the app's custom plugins and their error codes are typed exactly, and touches no database.

View Source
const (
	FlagPasswordStdin = "password-stdin"
)

Flags of the admin subcommands. The password is never a flag: argv is visible to every process on the machine and lands in shell history, so it arrives on stdin only (FlagPasswordStdin), as `docker login --password-stdin` does.

View Source
const ModulePath = "github.com/ubgo/auth"

ModulePath is the module whose version Info reports, read from the binary's build information. An app that vendors or replaces the module sees "(devel)" or the replacement's version, which is the truth about what it runs.

View Source
const SecretBytes = auth.MinSecretSize

SecretBytes is how many random bytes `auth secret` draws: auth.MinSecretSize, printed base64-encoded, so the value is longer than the minimum and holds the full 256 bits.

Variables

View Source
var (
	// ErrNoAdminPlugin: the Auth has no plugin with a Grant method.
	ErrNoAdminPlugin = errors.New("authcli: the admin plugin is not registered (auth.WithPlugins(admin.New()))")
	// ErrNoAddressPlugin: no plugin can find or set an address.
	ErrNoAddressPlugin = errors.New("authcli: no identifier plugin can set an address (auth.WithPlugins(email.New(...)))")
	// ErrNoPasswordPlugin: --password-stdin with no plugin that sets passwords.
	ErrNoPasswordPlugin = errors.New("authcli: no plugin can set a password (auth.WithPlugins(password.New()))")
	// ErrNoPassword: --password-stdin given and stdin was empty.
	ErrNoPassword = errors.New("authcli: --password-stdin: nothing was read from stdin")
)

Errors of the admin command. Each is a configuration or input problem the operator can act on; database errors pass through as they are.

Functions

func Main

func Main(ctx context.Context, a *auth.Auth, args []string, stdout, stderr io.Writer) int

Main runs one command and returns its exit code. args excludes the program name and the "auth" word the app routed on. Output meant for a pipe (the SQL, the status table) goes to stdout; usage and errors go to stderr. Every command but "migrate" without a subcommand, "sweep", "maintain" and "admin" is read-only. A password for `admin create --password-stdin` is read from the process's standard input; MainWithStdin takes another reader.

func MainWithStdin

func MainWithStdin(ctx context.Context, a *auth.Auth, args []string, stdin io.Reader, stdout, stderr io.Writer) int

MainWithStdin is Main with an explicit standard input, for an app that embeds the command line behind its own and for tests. Only `admin create --password-stdin` reads it.

Types

type AddressSetter

type AddressSetter interface {
	auth.Plugin
	Identifiers() []auth.IdentifierKind
	SetFor(ctx context.Context, userID, address string, verified bool) error
}

AddressSetter is what `auth admin` needs from the identifier plugin that owns addresses: its kind, to find a user by address, and SetFor, to give a created user a verified one. `*email.Plugin` has both. The kind is read from the plugin rather than named here, so this package never spells the email plugin's constant.

type AdminGranter

type AdminGranter interface {
	auth.Plugin
	Grant(ctx context.Context, userID string) error
}

AdminGranter is what `auth admin` needs from the admin plugin: the method `*admin.Plugin` has. Found through auth.PluginOf, so this package imports no plugin and an app registers nothing extra.

type AdminResult

type AdminResult struct {
	UserID string `json:"userId"`
	Email  string `json:"email"`
	// Created is true when `create` made the user; `grant` leaves it false.
	Created bool `json:"created"`
	// Granted is true when the user is now an admin by this command.
	Granted bool `json:"granted"`
	// PasswordSet is true when a password was read from stdin and set.
	PasswordSet bool `json:"passwordSet"`
	DryRun      bool `json:"dryRun"`
}

AdminResult is what `auth admin --json` prints: the user acted on and what was done. Under --dry-run nothing was done and DryRun says so, so a script never mistakes a rehearsal for the real thing.

type Duration

type Duration time.Duration

Duration is a time.Duration that encodes as its String form ("720h0m0s") in JSON and decodes from it. Info uses it because a bare time.Duration encodes as nanoseconds, which no person reading a support request can interpret, and because the round trip must hold: what --json prints decodes back into Info.

func (Duration) MarshalText

func (d Duration) MarshalText() ([]byte, error)

MarshalText encodes d as time.Duration's String form.

func (Duration) String

func (d Duration) String() string

String is time.Duration's String, so a table prints "720h0m0s".

func (*Duration) UnmarshalText

func (d *Duration) UnmarshalText(b []byte) error

UnmarshalText decodes the String form; anything time.ParseDuration refuses is an error, never a zero.

type Info

type Info struct {
	Version        string            `json:"version"`
	Engine         string            `json:"engine"`
	BaseURL        string            `json:"baseUrl"`
	TrustedOrigins []string          `json:"trustedOrigins"`
	FreshWindow    Duration          `json:"freshWindow"`
	Session        SessionInfo       `json:"session"`
	Plugins        []string          `json:"plugins"`
	Identifiers    []string          `json:"identifiers"`
	Operations     []OperationInfo   `json:"operations"`
	Disabled       []auth.EndpointID `json:"disabled"`
	Migrations     []MigrationRef    `json:"migrations"`
}

Info is what "auth info" prints: the wiring of this instance, for a support request or a deployment check. It holds no secret and no user data; the trusted origins and the base URL are the deployment's public addresses.

func InfoOf

func InfoOf(a *auth.Auth) (Info, error)

InfoOf collects Info for a. It touches no database: everything it reports was settled by auth.New. The error is the one Migrations returns for an engine the core has no DDL for.

type MigrationRef

type MigrationRef struct {
	Owner string `json:"owner"`
	Name  string `json:"name"`
}

MigrationRef names one migration in Info, without its statements.

type OperationInfo

type OperationInfo struct {
	ID      auth.EndpointID  `json:"id"`
	Kind    auth.OpKind      `json:"kind"`
	Require auth.Requirement `json:"require"`
	Route   string           `json:"route"`
}

OperationInfo is one route in Info.

type PasswordSetter

type PasswordSetter interface {
	auth.Plugin
	SetPassword(ctx context.Context, userID string, password auth.Token) error
}

PasswordSetter is what `auth admin create --password-stdin` needs from a password plugin: the method `*password.Plugin` has, found through auth.PluginOf, so the core module never imports the password module.

type SessionInfo

type SessionInfo struct {
	IdleTimeout          Duration `json:"idleTimeout"`
	MaxLifetime          Duration `json:"maxLifetime"`
	RefreshAfter         Duration `json:"refreshAfter"`
	TransientIdleTimeout Duration `json:"transientIdleTimeout"`
}

SessionInfo is auth.SessionConfig as Info reports it: the same four lifetimes, with JSON names and readable durations. auth.SessionConfig has no JSON tags because it is configuration, not a wire format; this is the wire format, so a rename in the core cannot silently change the output.

Jump to

Keyboard shortcuts

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