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
- Variables
- func HashPassword(password string) (string, error)
- func VerifyPassword(password, encoded string) (bool, error)
- type APIToken
- type AuthCode
- type RedeemedCode
- type Service
- func (s *Service) AdminExists(ctx context.Context) (bool, error)
- func (s *Service) Authenticate(ctx context.Context, username, password string) (*User, error)
- func (s *Service) CheckPassword(ctx context.Context, id int64, password string) error
- func (s *Service) ClearRecoveryCode(ctx context.Context, userID int64) error
- func (s *Service) ConsumePairingToken(ctx context.Context, secret string) (*User, error)
- func (s *Service) CountDemoUsers(ctx context.Context) (int, error)
- func (s *Service) CreateAuthCode(ctx context.Context, userID int64, label string, maxUses int, ...) (string, error)
- func (s *Service) CreateDemoUser(ctx context.Context, username string) (*User, error)
- func (s *Service) CreateInvite(ctx context.Context, userID int64, label string, maxUses int, ...) (string, error)
- func (s *Service) CreateUser(ctx context.Context, username, password, role string) (*User, error)
- func (s *Service) DeleteUser(ctx context.Context, id int64) error
- func (s *Service) GenerateRecoveryCode(ctx context.Context, userID int64) (string, error)
- func (s *Service) GetUser(ctx context.Context, id int64) (*User, error)
- func (s *Service) IssueAPIToken(ctx context.Context, userID int64, label string) (string, APIToken, error)
- func (s *Service) IssuePairingToken(ctx context.Context, rc *RedeemedCode) (string, error)
- func (s *Service) IssueToken(ctx context.Context, userID int64, kind, deviceName string, ttl time.Duration) (string, error)
- func (s *Service) ListAPITokens(ctx context.Context, userID int64) ([]APIToken, error)
- func (s *Service) ListAuthCodes(ctx context.Context, userID int64) ([]AuthCode, error)
- func (s *Service) ListUsers(ctx context.Context) ([]User, error)
- func (s *Service) ReapIdleDemoUsers(ctx context.Context, cutoff time.Time) (int64, error)
- func (s *Service) ResolveAuthCode(ctx context.Context, code string) (*RedeemedCode, error)
- func (s *Service) ResolveToken(ctx context.Context, secret, kind string) (*User, error)
- func (s *Service) ResolveTokenKinds(ctx context.Context, secret string, kinds ...string) (*User, string, error)
- func (s *Service) RevokeAuthCode(ctx context.Context, id int64) error
- func (s *Service) RevokeToken(ctx context.Context, secret string) error
- func (s *Service) RevokeTokenByID(ctx context.Context, userID, id int64) error
- func (s *Service) RotateAuthCode(ctx context.Context, id int64) (string, error)
- func (s *Service) SetDisabled(ctx context.Context, id int64, disabled bool) error
- func (s *Service) SetPassword(ctx context.Context, id int64, password string) error
- func (s *Service) SetRole(ctx context.Context, id int64, role string) error
- type User
Constants ¶
const ( RoleAdmin = "admin" RoleUser = "user" )
Roles.
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.
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.
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.
const MinPasswordLen = 8
MinPasswordLen is the minimum length for a non-empty account password.
Variables ¶
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 ¶
HashPassword returns an argon2id PHC-style encoded hash.
func VerifyPassword ¶
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 (*Service) AdminExists ¶
AdminExists reports whether at least one admin account is present.
func (*Service) Authenticate ¶
Authenticate verifies a username/password and returns the user.
func (*Service) CheckPassword ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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) 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 ¶
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 ¶
ListAPITokens returns a user's live (non-revoked) API keys, newest first, as metadata only (never a secret or hash).
func (*Service) ListAuthCodes ¶
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) ReapIdleDemoUsers ¶
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 ¶
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 ¶
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 ¶
RevokeAuthCode deletes an issued auth code by id, immediately invalidating it.
func (*Service) RevokeToken ¶
RevokeToken revokes a token by its secret.
func (*Service) RevokeTokenByID ¶
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 ¶
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 ¶
SetDisabled enables or disables an account. Disabling the last enabled admin is refused so the console can never be locked out.
func (*Service) SetPassword ¶
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).
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).