auth

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jul 31, 2026 License: MIT Imports: 23 Imported by: 0

Documentation

Overview

Package auth handles passwords, sessions, API keys and permission checks.

Index

Constants

View Source
const (
	PermAPIKeysRead  = "apikeys.read"
	PermAPIKeysWrite = "apikeys.write"
)

Permissions API key management itself requires.

View Source
const (
	SessionCookieName         = "__Host-linkctrl_session"
	SessionCookieNameInsecure = "linkctrl_session"
)

SessionCookieName uses the __Host- prefix, which browsers only accept when the cookie is Secure, has Path=/, and carries no Domain attribute. That makes it impossible for a subdomain — including one an attacker controls via a stale DNS record or a shared hosting neighbour — to set or overwrite the session cookie.

The prefix requires HTTPS, so local HTTP development uses the unprefixed name. Config refuses SECURE_COOKIES=false in production, so the weaker form cannot reach a real deployment.

View Source
const (

	// APIKeyPrefixLength is the length of the public, storable part.
	APIKeyPrefixLength = len(apiKeyTag) + apiKeyIDChars
)

Token layout: "lk_live_" + 8-character public id + "_" + 43-character secret.

The public id is stored and indexed, so verification is a single-row lookup rather than a scan comparing every hash. The tag is fixed-length and the id is fixed-length, which means the parts are taken by offset — splitting on "_" would break the moment a base64url secret contained one.

"live" is there so a future test-mode key is distinguishable by eye rather than by asking the database. The whole token is one word with no spaces or punctuation beyond underscores, so it survives being pasted into a shell, a YAML file and a CI secret box unquoted.

View Source
const MaxPasswordLength = 4096

MaxPasswordLength caps input before hashing.

Argon2 has no practical input limit, so this is not about the algorithm: it is a denial-of-service guard. Hashing is deliberately expensive, and an unbounded body means an attacker can make the server do unbounded work.

View Source
const MinPasswordLength = 12

MinPasswordLength is the floor for new passwords. Length is the only requirement — no composition rules, which push people toward predictable substitutions without adding real entropy (NIST SP 800-63B).

View Source
const MinPepperLength = 32

MinPepperLength mirrors the config validation floor, so a service built directly in a test cannot be weaker than a deployed one.

Variables

View Source
var (
	ErrMismatch      = errors.New("auth: password does not match")
	ErrInvalidHash   = errors.New("auth: hash is not in a recognised format")
	ErrUnsupportedID = errors.New("auth: unsupported password hash algorithm")
)
View Source
var (
	ErrEmailTaken         = errors.New("auth: email already registered")
	ErrInvalidEmail       = errors.New("auth: invalid email address")
	ErrInvalidCredentials = errors.New("auth: invalid email or password")
	ErrAccountLocked      = errors.New("auth: account temporarily locked")
	ErrAccountInactive    = errors.New("auth: account is not active")
	ErrSignupClosed       = errors.New("auth: registration is closed")
)
View Source
var (
	ErrSessionNotFound = errors.New("auth: session not found")
	ErrSessionExpired  = errors.New("auth: session expired")
	ErrSessionRevoked  = errors.New("auth: session revoked")
)
View Source
var DefaultLockout = LockoutPolicy{Threshold: 5, Window: 15 * time.Minute}
View Source
var DefaultParams = Params{
	MemoryKiB:   64 * 1024,
	Iterations:  3,
	Parallelism: 2,
	SaltLength:  16,
	KeyLength:   32,
}

DefaultParams follows the RFC 9106 second recommendation: 64 MiB, t=3, p=2. config.Validate refuses anything below the 19 MiB floor.

View Source
var ErrAPIKeyInvalid = errors.New("auth: api key is not valid")

ErrAPIKeyInvalid covers every reason a presented key does not authenticate: malformed, unknown, wrong secret, revoked, expired, or belonging to an account that is no longer active.

One error rather than several. The distinction is of no use to a legitimate caller — the key list shows revocation and expiry, so the owner can already see which of theirs is which — and separate responses would tell whoever found a leaked key whether it is still worth trying elsewhere.

View Source
var NonDelegableScopes = map[string]struct{}{
	PermAPIKeysRead:  {},
	PermAPIKeysWrite: {},
	"org.delete":     {},
}

NonDelegableScopes are permissions an API key may never hold, whatever its creator's role.

Key management is the important one: a key that can mint keys makes revocation meaningless, because whoever holds a leaked key simply issues another before the original is cut off. So minting stays behind an interactive session, and org.delete follows the same rule — an irreversible action should require a human sign-in rather than a token in a CI variable.

Functions

func APIKeyHash

func APIKeyHash(pepper []byte, prefix, secret string) []byte

APIKeyHash is the value stored in api_keys.key_hash.

HMAC-SHA256 with a pepper from configuration, so a database dump on its own does not permit offline verification. Deliberately not argon2: the secret is full-entropy random, so stretching buys nothing, and 64 MiB of work per request would not fit a 150ms API budget.

The prefix is part of the message, which binds a hash to the row that holds it: a hash copied to another key's row no longer verifies.

func AnonymizeIP

func AnonymizeIP(addr netip.Addr) string

AnonymizeIP reduces an address to the prefix kept for session and audit records: /24 for IPv4, /48 for IPv6.

The same reasoning as analytics — enough to recognise "this session moved to a different network", not enough to identify a person. Analytics keeps no address at all; sessions keep a prefix because "where was this session used" is a question a user legitimately asks of their own account.

func CookieName

func CookieName(secure bool) string

CookieName returns the correct cookie name for the deployment.

func HashSessionToken

func HashSessionToken(token string) []byte

HashSessionToken returns the storage hash for a token.

func IsSessionInvalid

func IsSessionInvalid(err error) bool

IsSessionInvalid reports whether an Authenticate failure means the credential itself is finished, as opposed to the lookup having failed.

The distinction decides whether a caller may destroy the cookie. Authenticate returns wrapped pgx errors for a dead pool, a cancelled context or a missing workspace row, and treating those as "this session is over" turns a ten-second database blip into a forced sign-out for every signed-in user at once — sessions that were, and remain, perfectly valid.

func NewSessionToken

func NewSessionToken() (token string, hash []byte, err error)

NewSessionToken returns a random token and its storage hash.

Only the hash is persisted. A database leak therefore does not hand over live sessions, which is the same reasoning as never storing a raw password. SHA-256 rather than argon2 is correct here: the token is full-entropy random, so key-stretching adds nothing, and session validation happens on every request where 64 MiB of work would be untenable.

func NormalizeEmail

func NormalizeEmail(email string) string

NormalizeEmail trims and lowercases. The database also stores a generated lowercase column, so comparison never depends on the caller remembering.

func ParseAPIKey

func ParseAPIKey(token string) (prefix, secret string, err error)

ParseAPIKey splits a token into its public prefix and its secret.

Everything about the shape is checked here so that a malformed token costs no database round trip, which is what stops a flood of junk Authorization headers turning into a flood of queries.

func ValidateEmail

func ValidateEmail(email string) error

Types

type APIKeyConfig

type APIKeyConfig struct {
	// Pepper keys the HMAC. Required; a short one is refused rather than
	// silently accepted, because a weak pepper is invisible in behaviour.
	Pepper []byte
	// UsageFlushInterval is how often buffered last_used_at values are
	// written. Coarse on purpose: the value answers "is this key still in
	// use", which does not need second resolution.
	UsageFlushInterval time.Duration
	Logger             *slog.Logger
}

APIKeyConfig configures the key service.

type APIKeyInfo

type APIKeyInfo struct {
	ID         uuid.UUID  `json:"id"`
	Name       string     `json:"name"`
	Prefix     string     `json:"prefix"`
	Scopes     []string   `json:"scopes"`
	LastUsedAt *time.Time `json:"last_used_at"`
	ExpiresAt  *time.Time `json:"expires_at"`
	RevokedAt  *time.Time `json:"revoked_at"`
	CreatedAt  time.Time  `json:"created_at"`
}

APIKeyInfo is a key as its owner sees it. The secret is absent by construction: it is never stored, so it cannot be listed.

type APIKeyService

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

APIKeyService issues, lists, revokes and authenticates API keys.

It sits alongside Service rather than inside it because the two answer different questions with different inputs — a password and a cookie versus a bearer token and a pepper — and only this one needs a secret from configuration. Both resolve to the same Identity, so nothing downstream can tell which credential a request arrived with unless it asks.

func NewAPIKeyService

func NewAPIKeyService(pool *pgxpool.Pool, authSvc *Service, cfg APIKeyConfig) (*APIKeyService, error)

func (*APIKeyService) Authenticate

func (s *APIKeyService) Authenticate(ctx context.Context, token string) (*Identity, error)

Authenticate resolves a bearer token to an identity.

The identity's permissions are the intersection of the owner's current role and the key's scopes, recomputed on every request. So demoting a user weakens their keys at once, and a scope the role no longer grants stops working without the key having to be reissued.

func (*APIKeyService) Close

func (s *APIKeyService) Close(ctx context.Context) error

Close flushes buffered usage timestamps and stops the writer.

func (*APIKeyService) Create

Create issues a key and returns the only copy of its token.

The token is not recoverable afterwards by design: only the HMAC is stored, which is the same reasoning as never storing a password. A caller who loses it revokes the key and issues another.

func (*APIKeyService) FlushUsage

func (s *APIKeyService) FlushUsage(ctx context.Context) error

FlushUsage writes buffered last_used_at values immediately. Called by Close, and by tests that would otherwise have to sleep.

func (*APIKeyService) List

func (s *APIKeyService) List(ctx context.Context, actor *Identity) ([]APIKeyInfo, error)

List returns the actor's own keys.

Own, not the workspace's: a key is a personal credential acting as its owner, and showing one user another's credentials serves no purpose that listing memberships does not serve better.

func (*APIKeyService) Revoke

func (s *APIKeyService) Revoke(ctx context.Context, actor *Identity, id uuid.UUID) error

Revoke disables a key immediately.

Immediately in the literal sense: nothing about a key is cached, so the next request presenting it fails. That is the reason revocation is checked in the verification query rather than kept in a cache alongside the hash.

func (*APIKeyService) Start

func (s *APIKeyService) Start()

Start launches the background writer for last_used_at.

type CreateAPIKeyInput

type CreateAPIKeyInput struct {
	Name      string
	Scopes    []string
	ExpiresAt *time.Time
}

CreateAPIKeyInput describes a new key.

type CreatedAPIKey

type CreatedAPIKey struct {
	APIKeyInfo
	Key string `json:"key"`
}

CreatedAPIKey is the response to creating a key: the record, plus the only copy of the token that will ever exist.

type Hasher

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

Hasher hashes and verifies passwords.

The semaphore is the reason this is a struct rather than free functions. Each hash allocates 64 MiB, so N concurrent logins allocate N x 64 MiB; a credential-stuffing burst would otherwise OOM the process. Limiting concurrent hashing bounds that at a fixed cost, and the login rate limiter keeps the queue behind it short.

func NewHasher

func NewHasher(p Params) *Hasher

func (*Hasher) DummyVerify

func (h *Hasher) DummyVerify(password string)

DummyVerify performs a hash with the same cost as a real verification and discards the result.

Called when the account does not exist, so that login timing does not reveal whether an email is registered. Without it, "no such user" returns in microseconds while a real user costs ~50ms, which is a trivially measurable account-enumeration oracle.

func (*Hasher) Hash

func (h *Hasher) Hash(password string) (string, error)

Hash returns a PHC-encoded argon2id hash.

func (*Hasher) NeedsRehash

func (h *Hasher) NeedsRehash(encoded string) bool

NeedsRehash reports whether a stored hash was made with weaker parameters than the current policy. Callers rehash on the next successful login, which is the only moment the plaintext is available.

func (*Hasher) Verify

func (h *Hasher) Verify(password, encoded string) error

Verify checks a password against a stored hash.

Returns ErrMismatch for a wrong password and a different error for a malformed hash, so a corrupt row is not silently reported to the user as "wrong password" while the real problem goes uninvestigated.

type Identity

type Identity struct {
	UserID      uuid.UUID
	Email       string
	Name        string
	WorkspaceID uuid.UUID
	OrgID       uuid.UUID
	SessionID   uuid.UUID
	Role        string
	// APIKeyID is set when the request authenticated with an API key instead
	// of a session cookie. Services consult it for the few operations that
	// must require an interactive sign-in; everything else is deliberately
	// blind to which credential was used.
	APIKeyID *uuid.UUID
	// contains filtered or unexported fields
}

Identity is an authenticated user together with the workspace they are acting in. Both the REST handlers and the dashboard handlers resolve to this same type, so authorization cannot diverge between the two surfaces.

func (*Identity) Can

func (i *Identity) Can(permission string) bool

Can reports whether the identity holds a permission.

This is the RBAC evaluator, and it is deliberately called from the service layer rather than from middleware. Middleware only knows the route; the service knows which workspace the object being touched belongs to, which is the question that actually matters.

func (*Identity) IsAPIKey

func (i *Identity) IsAPIKey() bool

IsAPIKey reports whether the request authenticated with an API key.

func (*Identity) Permissions

func (i *Identity) Permissions() []string

Permissions returns the identity's permissions, for API-key scope intersection and for rendering the UI.

type LockoutPolicy

type LockoutPolicy struct {
	Threshold int
	Window    time.Duration
}

LockoutPolicy throttles repeated failed logins for one account.

Per-account, complementing the per-IP rate limit. Neither alone is enough: per-IP misses a distributed attack on one account, and per-account lets an attacker lock a victim out by failing on purpose — which is why this uses a short expiring window rather than a lock an administrator must clear.

func (LockoutPolicy) LockedUntil

func (p LockoutPolicy) LockedUntil(failedCount int, lastFailure time.Time, now time.Time) time.Time

LockedUntil returns when a lockout expires, or the zero time if the account is not locked.

func (LockoutPolicy) ThresholdParam

func (p LockoutPolicy) ThresholdParam() int32

ThresholdParam and WindowSecondsParam narrow the policy for the SQL that applies it.

Clamped, not converted. A configured value large enough to wrap would arrive in the query as a negative threshold, and `failed_login_count + 1 >= -3` is true on the first attempt — a nonsense setting would lock every account out on one typo instead of being ignored.

func (LockoutPolicy) WindowSecondsParam

func (p LockoutPolicy) WindowSecondsParam() int32

type LoginInput

type LoginInput struct {
	Email     string
	Password  string
	IP        netip.Addr
	UserAgent string
}

LoginInput is a sign-in attempt.

type LoginResult

type LoginResult struct {
	Identity *Identity
	Token    string
	Expires  time.Time
}

type Params

type Params struct {
	MemoryKiB   uint32
	Iterations  uint32
	Parallelism uint8
	SaltLength  uint32
	KeyLength   uint32
}

Params are the argon2 cost parameters.

Stored in the hash string itself (PHC format), so changing these does not invalidate existing passwords: an old hash still verifies against its own recorded parameters, and NeedsRehash reports that it should be upgraded on the next successful login.

type RegisterInput

type RegisterInput struct {
	Email    string
	Name     string
	Password string
	// IsFirstUser marks the setup flow, which is permitted even when signup is
	// closed — otherwise a fresh closed instance could never create its first
	// account.
	IsFirstUser bool
}

RegisterInput describes a new account.

type Service

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

Service owns registration, login and session lifecycle.

func NewService

func NewService(pool *pgxpool.Pool, cfg ServiceConfig) *Service

func (*Service) Authenticate

func (s *Service) Authenticate(ctx context.Context, token string) (*Identity, error)

Authenticate resolves a session token to an identity.

func (*Service) ChangePassword

func (s *Service) ChangePassword(ctx context.Context, userID, keepSession uuid.UUID, current, next string) error

ChangePassword updates a password and logs out every other session.

func (*Service) Hasher

func (s *Service) Hasher() *Hasher

Hasher exposes the configured hasher for the CLI, which creates users outside a request.

func (*Service) IdentityForEmail

func (s *Service) IdentityForEmail(ctx context.Context, email string) (*Identity, error)

IdentityForEmail resolves a user to an identity without a session.

For the CLI, which acts as a named user rather than as root: `lctl apikey create` goes through the same service call and the same permission checks a request would, so the CLI cannot mint a key the user could not.

func (*Service) Login

func (s *Service) Login(ctx context.Context, in LoginInput) (*LoginResult, error)

Login authenticates and starts a session.

Every failure returns ErrInvalidCredentials regardless of cause — unknown email, wrong password, no local password set. Distinguishing them tells an attacker which addresses are registered.

func (*Service) Logout

func (s *Service) Logout(ctx context.Context, sessionID uuid.UUID) error

Logout revokes one session.

func (*Service) NeedsSetup

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

NeedsSetup reports whether the instance has no users yet.

func (*Service) Register

func (s *Service) Register(ctx context.Context, in RegisterInput) (*Identity, error)

Register creates a user with their personal organization, workspace and owner membership, in one transaction.

Provisioning all four together is what lets Phase 1 behave as a single-user product while every row already carries the tenancy columns Phase 2 needs. A user without a workspace would be a state no other code path expects, so it must not be possible to observe one.

type ServiceConfig

type ServiceConfig struct {
	Params  Params
	TTL     SessionTTL
	Lockout LockoutPolicy
}

type Session

type Session struct {
	ID         uuid.UUID
	UserID     uuid.UUID
	CreatedAt  time.Time
	LastSeenAt time.Time
	ExpiresAt  time.Time
}

Session is a live login.

type SessionTTL

type SessionTTL struct {
	// Absolute is the hard deadline from creation. A session dies at this
	// point regardless of activity, which bounds how long a stolen token stays
	// useful.
	Absolute time.Duration
	// Idle is the maximum gap between requests. Enforced against last_seen_at
	// at read time rather than by rewriting expires_at, so changing the policy
	// takes effect immediately and needs no data migration.
	Idle time.Duration
}

SessionTTL bundles the two expiry rules.

func (SessionTTL) Valid

func (t SessionTTL) Valid(s Session, now time.Time) error

Valid checks a session against both expiry rules.

Jump to

Keyboard shortcuts

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