auth

package
v1.13.0 Latest Latest
Warning

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

Go to latest
Published: Aug 14, 2026 License: AGPL-3.0 Imports: 14 Imported by: 0

Documentation

Overview

Package auth manages users, opaque session tokens, redeemable auth codes and the QR pairing payload used by the "enter your auth code" connect flow.

Index

Constants

View Source
const (
	RoleAdmin = "admin"
	RoleUser  = "user"
)

Roles.

View Source
const (
	KindSession = "session"
	KindPairing = "pairing"
	// KindAPI is a user-minted, non-expiring personal access token ("API key")
	// for headless integrations (dashboards, cron). It authenticates exactly like
	// a session token - anywhere requireAuth accepts a session it accepts an api
	// key, acting as its owner - but it is never valid for pairing exchange, and
	// its lifecycle is mint/list/revoke rather than sign-in/sign-out.
	KindAPI = "api"
)

Token kinds.

View Source
const (
	CodeInvite   = "invite"
	CodeRecovery = "recovery"
)

Auth code kinds. An invite is an admin-minted onboarding secret (bounded uses and lifetime); a recovery code is a durable, reusable credential the user holds to re-authenticate themselves after signing out or losing a device - so recovery never needs an admin to mint a fresh invite. Both redeem through the same path; only their ownership and lifetime differ.

View Source
const DemoUsernamePrefix = "demo_"

DemoUsernamePrefix marks throwaway accounts created by public demo mode. The demo session endpoint creates `demo_<random>` users and the background reaper deletes idle ones by this prefix.

View Source
const MinPasswordLen = 8

MinPasswordLen is the minimum length for a non-empty account password.

Variables

View Source
var (
	ErrNotFound     = errors.New("not found")
	ErrInvalidCreds = errors.New("invalid credentials")
	ErrInvalidToken = errors.New("invalid or expired token")
	ErrInvalidCode  = errors.New("invalid or expired auth code")
	// ErrCodeExhausted is returned by ConsumePairingToken when the parent invite
	// has no uses left, so the transport can tell "the invite is spent" apart
	// from a bogus token.
	ErrCodeExhausted = errors.New("invite has no uses left")
	// ErrCodeExpired is returned by ConsumePairingToken when the parent invite
	// expired between redeem and exchange.
	ErrCodeExpired = errors.New("invite has expired")
	// ErrLastAdmin is returned when an operation would leave no enabled admin.
	ErrLastAdmin = errors.New("cannot remove the last admin")
	// ErrAdminNeedsPassword is returned when an account would become (or remain)
	// an admin without a password to sign in to the console.
	ErrAdminNeedsPassword = errors.New("admin accounts require a password")
	// ErrPasswordTooShort is returned when a non-empty password is below the
	// minimum length.
	ErrPasswordTooShort = errors.New("password must be at least 8 characters")
	// ErrUsernameTaken is returned when creating a user whose username already
	// exists, so the transport layer can map it to 409 instead of echoing the raw
	// SQLite unique-constraint string.
	ErrUsernameTaken = errors.New("username already taken")
)

Errors returned by the service.

Functions

func HashPassword

func HashPassword(password string) (string, error)

HashPassword returns an argon2id PHC-style encoded hash.

func VerifyPassword

func VerifyPassword(password, encoded string) (bool, error)

VerifyPassword reports whether password matches the encoded argon2id hash. Comparison is constant time.

Types

type APIToken

type APIToken struct {
	ID        int64   `json:"id"`
	Label     string  `json:"label"`
	CreatedAt string  `json:"created_at"`
	LastSeen  *string `json:"last_seen"`
}

APIToken describes a user-minted API key for display. The plaintext secret is never included - only its SHA-256 hash is stored, by design - so this carries the key's metadata only. LastSeen is nil until the key has authenticated a request (rendered as JSON null).

type AuthCode

type AuthCode struct {
	ID         int64  `json:"id"`
	Label      string `json:"label"`
	MaxUses    int    `json:"max_uses"` // 0 = unlimited
	Uses       int    `json:"uses"`
	ExpiresAt  string `json:"expires_at,omitempty"`  // empty = no expiry
	RedeemedAt string `json:"redeemed_at,omitempty"` // empty = never redeemed (pending)
	CreatedAt  string `json:"created_at"`
}

AuthCode describes an issued auth code for admin display. The plaintext code is never included - only its hash is stored, by design - so this carries the code's metadata only (label, lifetimes and usage).

type RedeemedCode

type RedeemedCode struct {
	User    *User
	CodeID  int64
	Kind    string // CodeInvite | CodeRecovery
	MaxUses int    // 0 = unlimited
	Uses    int
	// ExpiresAt is the code's RFC3339 UTC expiry, "" = never.
	ExpiresAt string
}

RedeemedCode is a validated auth code resolved WITHOUT consuming a use. The use is claimed when a device actually pairs (ConsumePairingToken), so opening an invite link never burns a use on its own.

func (*RedeemedCode) UsesRemaining

func (rc *RedeemedCode) UsesRemaining() *int

UsesRemaining reports how many more devices can pair via this code, or nil for unlimited. Always >= 1: ResolveAuthCode (the only constructor) rejects exhausted codes. Advisory: concurrent exchanges may consume uses after it is computed.

type Service

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

Service provides authentication and account operations backed by the store.

func New

func New(db *store.DB, now func() time.Time) *Service

New returns a Service. now may be nil to use time.Now.

func (*Service) AdminExists

func (s *Service) AdminExists(ctx context.Context) (bool, error)

AdminExists reports whether at least one admin account is present.

func (*Service) Authenticate

func (s *Service) Authenticate(ctx context.Context, username, password string) (*User, error)

Authenticate verifies a username/password and returns the user.

func (*Service) CheckPassword

func (s *Service) CheckPassword(ctx context.Context, id int64, password string) error

CheckPassword verifies a plaintext password against a user's stored hash, returning nil on match and ErrInvalidCreds otherwise (including for a password-less account). Used to challenge a self-service password change.

func (*Service) ClearRecoveryCode

func (s *Service) ClearRecoveryCode(ctx context.Context, userID int64) error

ClearRecoveryCode removes a user's recovery code, if any. Wired to both the user's own DELETE /auth/recovery and the admin's per-user revoke.

func (*Service) ConsumePairingToken

func (s *Service) ConsumePairingToken(ctx context.Context, secret string) (*User, error)

ConsumePairingToken validates a pairing token and consumes it, returning the bound user. An UNLINKED token (minted by /auth/pair, the demo flow, or pre-migration) is atomically revoked - strictly single-use, and the revoke-if-not-revoked write means two racing exchanges cannot both win. A LINKED token instead atomically claims one use on its parent code - folding the cap check, the code-expiry check, and the first-claim redeemed_at stamp into one UPDATE - and is NOT revoked: the code's cap and expiry govern how many more devices may pair with it. A disabled user is rejected before any use is consumed.

func (*Service) CountDemoUsers

func (s *Service) CountDemoUsers(ctx context.Context) (int, error)

CountDemoUsers returns the number of live demo accounts (is_demo = 1). Used to cap how many can exist at once.

func (*Service) CreateAuthCode

func (s *Service) CreateAuthCode(ctx context.Context, userID int64, label string, maxUses int, ttl time.Duration) (string, error)

CreateAuthCode generates a redeemable invite code bound to a user, without the supersede-on-mint hygiene - used by the first-run bootstrap, which has no prior invites to supersede. maxUses 0 means unlimited; ttl <= 0 means no expiry. The code is returned once. Admin minting goes through CreateInvite.

func (*Service) CreateDemoUser

func (s *Service) CreateDemoUser(ctx context.Context, username string) (*User, error)

CreateDemoUser creates a password-less, non-admin throwaway account flagged is_demo so the background reaper can sweep idle ones by flag (never by username prefix, which would catch real accounts an admin named "demo_*").

func (*Service) CreateInvite

func (s *Service) CreateInvite(ctx context.Context, userID int64, label string, maxUses int, ttl time.Duration) (string, error)

CreateInvite mints a fresh invite for a user and, in the same transaction, supersedes the user's currently-active (still-redeemable) invites so there is exactly one active invite per user. Spent (used-up) and expired invites are left untouched as history. The code is returned once.

func (*Service) CreateUser

func (s *Service) CreateUser(ctx context.Context, username, password, role string) (*User, error)

CreateUser creates an account and returns it. A password is required for admins (who sign in to the console); non-admins may be created password-less (password == ""), in which case they onboard purely via auth-code pairing and can never password-login. An empty password is stored as an empty hash, never a hash of "".

func (*Service) DeleteUser

func (s *Service) DeleteUser(ctx context.Context, id int64) error

DeleteUser permanently removes an account. Deleting the last enabled admin is refused (ErrLastAdmin) so the console can never be locked out; deleting an unknown id is ErrNotFound. All of the user's durable state - sessions, auth codes, progress, bookmarks, notes, listening history and share grants - is removed by the schema's ON DELETE CASCADE rules (foreign_keys is ON, see store.Open). Files on disk are untouched (the library is the source of truth).

func (*Service) GenerateRecoveryCode

func (s *Service) GenerateRecoveryCode(ctx context.Context, userID int64) (string, error)

GenerateRecoveryCode mints a durable, reusable recovery code the user holds to re-authenticate after signing out or losing a device. It atomically replaces any existing recovery code for the user (so there is always at most one) and is returned once. Recovery codes never expire and have no use cap; they redeem through the same path as invites.

func (*Service) GetUser

func (s *Service) GetUser(ctx context.Context, id int64) (*User, error)

GetUser returns a user by ID.

func (*Service) IssueAPIToken

func (s *Service) IssueAPIToken(ctx context.Context, userID int64, label string) (string, APIToken, error)

IssueAPIToken mints a personal API key (kind=api, no expiry) for a user and returns the plaintext secret (shown once) plus the stored row's metadata for the create response. The secret is stored only as a hash. label is carried in device_name; callers trim/validate it before calling.

func (*Service) IssuePairingToken

func (s *Service) IssuePairingToken(ctx context.Context, rc *RedeemedCode) (string, error)

IssuePairingToken mints a pairing token linked to rc's code, making the QR built from it as redeemable as the code itself: exchange claims a use on the code, and the token dies with the code (cascade on delete/supersede, revoke on rotate). Invite-kind tokens carry no expiry of their own - the parent invite's expiry and use cap govern them at exchange, which is what lets ConsumePairingToken report "invite has expired" rather than a generic token error. Recovery-kind tokens get recoveryPairingTTL instead.

func (*Service) IssueToken

func (s *Service) IssueToken(ctx context.Context, userID int64, kind, deviceName string, ttl time.Duration) (string, error)

IssueToken creates a token of the given kind for a user and returns the secret (shown once). ttl <= 0 means no expiry. Tokens minted here are unlinked (no parent auth code); pairing tokens derived from a redeemed code go through IssuePairingToken instead.

func (*Service) ListAPITokens

func (s *Service) ListAPITokens(ctx context.Context, userID int64) ([]APIToken, error)

ListAPITokens returns a user's live (non-revoked) API keys, newest first, as metadata only (never a secret or hash).

func (*Service) ListAuthCodes

func (s *Service) ListAuthCodes(ctx context.Context, userID int64) ([]AuthCode, error)

ListAuthCodes returns the invite codes issued for a user, newest first. Recovery codes are deliberately excluded - they are user-owned and surfaced to the admin only as the User.HasRecovery flag, never as an actionable invite.

func (*Service) ListUsers

func (s *Service) ListUsers(ctx context.Context) ([]User, error)

ListUsers returns all accounts ordered by username.

func (*Service) ReapIdleDemoUsers

func (s *Service) ReapIdleDemoUsers(ctx context.Context, cutoff time.Time) (int64, error)

ReapIdleDemoUsers deletes demo accounts (is_demo = 1) whose most recent token activity (or, lacking any, their creation time) is older than cutoff. Their child rows (progress, bookmarks, notes, history, tokens, share grants) cascade via ON DELETE CASCADE. Returns the number of accounts deleted. Timestamps are stored as RFC3339 UTC, so the lexical comparison is chronological.

func (*Service) ResolveAuthCode

func (s *Service) ResolveAuthCode(ctx context.Context, code string) (*RedeemedCode, error)

ResolveAuthCode validates a presented code and returns it with its bound user - without consuming a use. Expired, exhausted, and disabled-/deleted-user codes are rejected (so the caller never renders a QR that could not exchange), but nothing is written: no use is burned and redeemed_at is not stamped. The caller typically then mints a linked pairing token via IssuePairingToken.

func (*Service) ResolveToken

func (s *Service) ResolveToken(ctx context.Context, secret, kind string) (*User, error)

ResolveToken validates a presented token secret of exactly the given kind and returns its user, also bumping last_seen. Revoked/expired tokens return ErrInvalidToken.

func (*Service) ResolveTokenKinds

func (s *Service) ResolveTokenKinds(ctx context.Context, secret string, kinds ...string) (*User, string, error)

ResolveTokenKinds is ResolveToken generalized over several accepted kinds: it validates a presented secret whose kind is one of kinds, bumps last_seen and returns the user together with the kind that actually matched. Middleware uses it to accept a session OR an api key on the same route (an api key acts as its owner) while never accepting a pairing token there, and uses the returned kind to bar an api key from routes that mint a fresh durable credential (see denyAPIKey). At least one kind must be supplied.

func (*Service) RevokeAuthCode

func (s *Service) RevokeAuthCode(ctx context.Context, id int64) error

RevokeAuthCode deletes an issued auth code by id, immediately invalidating it.

func (*Service) RevokeToken

func (s *Service) RevokeToken(ctx context.Context, secret string) error

RevokeToken revokes a token by its secret.

func (*Service) RevokeTokenByID

func (s *Service) RevokeTokenByID(ctx context.Context, userID, id int64) error

RevokeTokenByID revokes a user's own API key by id. It is scoped to the owner and to kind=api, so it never touches another user's token nor a session/ pairing token: an id matching no live api key of this user returns ErrNotFound (the transport maps it to 404). Backs DELETE /auth/tokens/{id}.

func (*Service) RotateAuthCode

func (s *Service) RotateAuthCode(ctx context.Context, id int64) (string, error)

RotateAuthCode regenerates an existing invite's secret in place: the old code stops working and a fresh one is returned (once), without leaving a new row behind. The invite's max_uses is preserved, its use counter and redeemed_at are reset (pending again), and its expiry is renewed for the same window it was originally granted (so resending a nearly-/already-expired invite yields a usable one). Pairing tokens linked to the code are revoked in the same transaction - the row survives rotation, so the delete cascade never fires - which disconnects any QR still on screen from the old secret. Only invite-kind codes rotate (recovery codes are user-owned); a missing or non-invite id returns ErrNotFound. This backs the admin "Resend".

func (*Service) SetDisabled

func (s *Service) SetDisabled(ctx context.Context, id int64, disabled bool) error

SetDisabled enables or disables an account. Disabling the last enabled admin is refused so the console can never be locked out.

func (*Service) SetPassword

func (s *Service) SetPassword(ctx context.Context, id int64, password string) error

SetPassword sets (or clears) an account's password. A non-empty password is hashed with argon2id; an empty password clears it (only valid for non-admins).

func (*Service) SetRole

func (s *Service) SetRole(ctx context.Context, id int64, role string) error

SetRole changes an account's role. Demoting the last enabled admin is refused. Promoting a password-less account to admin requires a password to be set first (see SetPassword) - admins must be able to sign in to the console.

type User

type User struct {
	ID       int64  `json:"id"`
	Username string `json:"username"`
	Role     string `json:"role"`
	Disabled bool   `json:"disabled"`
	// HasPassword reports whether the account can sign in with a password. It is
	// false for password-less accounts (non-admins onboarded purely via auth-code
	// pairing); such accounts never satisfy Authenticate.
	HasPassword bool `json:"has_password"`
	// HasRecovery reports whether the user holds a durable recovery code they can
	// use to re-authenticate without an admin. Drives the "you have no way back
	// in" warning shown at sign-out.
	HasRecovery bool `json:"has_recovery"`
	// IsDemo marks a throwaway demo account. Self-service password/recovery are
	// refused for demo accounts so a public demo can't be turned into a durable
	// login that outlives the idle reaper.
	IsDemo bool `json:"is_demo"`
	// LastSeenAt is the RFC3339 time of the user's most recent authenticated API
	// activity, derived from the newest tokens.last_seen across their tokens
	// (empty if they have never made an authenticated request).
	LastSeenAt string `json:"last_seen_at,omitempty"`
}

User is an account record (without the password hash for callers).

Jump to

Keyboard shortcuts

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