userauth

package
v0.1.8 Latest Latest
Warning

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

Go to latest
Published: Aug 17, 2026 License: Apache-2.0 Imports: 34 Imported by: 0

Documentation

Index

Constants

This section is empty.

Variables

View Source
var (
	ErrMFANotEnrolled = errors.New("userauth: mfa not enrolled")
	ErrMFAInvalidCode = errors.New("userauth: invalid mfa code")
	ErrMFAToken       = errors.New("userauth: invalid or expired mfa token")
	ErrMFAConfig      = errors.New("userauth: mfa not configured")
)

MFA sentinel errors mapped to HTTP status by the handlers.

View Source
var (
	ErrOAuthDisabled   = errors.New("userauth: oauth provider not configured")
	ErrOAuthState      = errors.New("userauth: invalid oauth state")
	ErrOAuthExchange   = errors.New("userauth: oauth code exchange failed")
	ErrOAuthNoEmail    = errors.New("userauth: oauth provider returned no email")
	ErrOAuthNoAutoUser = errors.New("userauth: oauth auto-provisioning disabled (no default role)")
)

OAuth sentinel errors mapped to HTTP status by the handlers.

View Source
var (
	ErrSignupDisabled     = errors.New("userauth: public signup is disabled")
	ErrInvalidEmail       = errors.New("userauth: invalid email")
	ErrWeakPassword       = errors.New("userauth: password too short")
	ErrInvalidCredentials = errors.New("userauth: invalid credentials")
	ErrTooManyAttempts    = errors.New("userauth: too many login attempts")
	ErrInvalidToken       = errors.New("userauth: invalid or expired token")
	ErrTenantMismatch     = errors.New("userauth: token tenant mismatch")
	ErrEmailNotVerified   = errors.New("userauth: email not verified")
	ErrUserSuspended      = errors.New("userauth: account suspended")
	ErrTenantSuspended    = errors.New("userauth: tenant suspended")
)

Sentinel errors the handlers map to HTTP status codes. Login NEVER distinguishes "unknown email" from "wrong password": both surface as ErrInvalidCredentials (anti-enumeration). Signup duplicate is the one case that reports existence (ErrEmailTaken → 409), the documented, conventional signup behaviour.

View Source
var ErrEmailTaken = errors.New("userauth: email already registered")

ErrEmailTaken is returned by Create when the email already exists IN THIS TENANT's schema. The same email in another tenant's schema is a different row and does not conflict.

View Source
var ErrIdentityExists = errors.New("userauth: identity already linked")

ErrIdentityExists signals a concurrent identity link (unique violation).

View Source
var ErrUserNotFound = errors.New("userauth: user not found")

ErrUserNotFound is returned by GetByEmail when no user matches in the tenant.

Functions

func HashPassword

func HashPassword(password string) (string, error)

HashPassword derives an argon2id hash of password and returns it encoded as a PHC string ($argon2id$v=19$m=...,t=...,p=...$salt$hash). A fresh 16-byte random salt is generated per call, so two identical passwords hash to different strings.

func HashToken

func HashToken(plain string) string

HashToken returns the SHA-256 hex hash used to store reset/verify tokens and backup codes (only the hash is ever persisted; the plain value never is).

func NewBackupCodes

func NewBackupCodes(n int) (display, hashes []string, err error)

NewBackupCodes returns n one-time recovery codes: the display forms (shown once) and their SHA-256 hashes (stored).

func NewTOTPSecret

func NewTOTPSecret() (string, error)

NewTOTPSecret returns a fresh random base32 TOTP secret (RFC 6238).

func NormalizeBackupCode

func NormalizeBackupCode(code string) string

NormalizeBackupCode strips formatting from a backup code so it verifies whether typed "ABCD-EFGH", "abcdefgh" or "abcd efgh".

func OTPAuthURI

func OTPAuthURI(issuer, account, secretB32 string) string

OTPAuthURI builds the otpauth://totp/… URI an authenticator app scans.

func TOTPCodeNow

func TOTPCodeNow(secretB32 string) (string, error)

TOTPCodeNow computes the current TOTP code for a base32 secret. It is the client side of TOTP (what an authenticator app shows) — exported mainly so an automated flow (a test, or a programmatic enrollment) can complete the enable/confirm handshake without a human typing a code.

func ValidateEmail

func ValidateEmail(email string) (normalized string, ok bool)

ValidateEmail normalizes (trim + lowercase) and format-checks an email for callers OUTSIDE this package that create users (the library's Ctx.CreateUser) — one email rule for signup, admin API and custom handlers. ok is false when the address is not acceptable; normalized is always the canonical form.

func ValidateTOTPNow

func ValidateTOTPNow(secretB32, code string) bool

ValidateTOTPNow reports whether code is a valid TOTP for secretB32 right now (±1 step / ±30 s), the same window the per-tenant MFA uses.

func VerifyPassword

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

VerifyPassword reports whether password matches the argon2id PHC string in encodedHash. It re-derives the hash using the PARAMETERS STORED IN THE STRING (not the current defaults) so old hashes keep verifying after a cost bump, and compares in constant time. A malformed stored hash returns (false, errInvalidHash); a plain mismatch returns (false, nil).

Types

type AuthResult

type AuthResult struct {
	User        PublicUser `json:"user"`
	Token       string     `json:"token,omitempty"`
	MFARequired bool       `json:"mfa_required,omitempty"`
	MFAToken    string     `json:"mfa_token,omitempty"`
}

AuthResult is returned by Signup and Login: the user (no hash) plus a freshly minted, engine-valid JWT (signup auto-logs-in). When a password login hits a user with MFA enabled, Token is empty and MFARequired+MFAToken are set instead: the final JWT is withheld until /auth/mfa/verify succeeds.

type Config

type Config struct {
	// JWTSecret signs login/refresh tokens — the SAME secret the engine's JWT
	// middleware validates with, so a login token is indistinguishable from an
	// externally-minted one (one claims contract, no second token path).
	JWTSecret string
	// SignupRole is the role assigned to every PUBLIC signup. An empty SignupRole
	// DISABLES public signup (POST /auth/signup → 403): safe by default, no
	// accidental self-service account creation. A client-supplied role is always
	// ignored (a public endpoint must never let a caller pick its own role —
	// privilege-escalation guard).
	SignupRole string
	// MinPasswordLength is the minimum accepted password length (default 8).
	MinPasswordLength int
	// TokenTTL is the lifetime of issued tokens (default 24h, matching the
	// engine's GenerateToken default).
	TokenTTL time.Duration
	// LoginAttemptsPerMinute / LoginBurst bound login attempts per (tenant,email)
	// (defaults 5 / 5) — online brute-force defence on top of the tenant limiter.
	LoginAttemptsPerMinute int
	LoginBurst             int

	// EmailTopic is the outbox topic the reset/verify flows enqueue email events
	// to (default "email.send"). It MUST match the email worker's
	// APPXIMO_EMAIL_TOPIC so the consumer picks the events up.
	EmailTopic string
	// BaseURL optionally overrides the origin used to build email links
	// (e.g. "https://acme.example.com"). Empty ⇒ the link origin is derived from
	// the request Host, which is the multi-tenant-correct default (the link points
	// back at the tenant subdomain the request arrived on).
	BaseURL string
	// RequireVerified, when true, blocks login for a user whose email_verified is
	// false (→ 403). Default false: AUTH-CORE's login flow is unchanged unless an
	// app opts in to mandatory verification.
	RequireVerified bool

	// TenantActive, when set, is consulted on LOGIN to block a suspended tenant
	// (the admin API suspends a tenant by flipping a control-plane flag; this
	// predicate reads it). It runs ONLY on the login path — never the CRUD/JWT hot
	// path — so a suspended tenant can mint no NEW sessions while the measured p50
	// is untouched. nil ⇒ every tenant is active (zero overhead, current behaviour).
	TenantActive func(ctx context.Context, tenantID string) bool

	// --- AUTH-OAUTH-V1: social login ---
	// OAuthProviders maps a provider name ("google"/"github"/"microsoft") to its
	// client credentials. A provider with an empty ClientID is NOT offered (an
	// unconfigured provider never fails the boot). Empty map ⇒ OAuth fully off.
	OAuthProviders map[string]OAuthProviderConfig
	// OAuthCallbackURL is the FIXED public origin the provider redirects back to
	// (e.g. "https://auth.example.com"). It must be the redirect URI registered
	// with each provider. Empty ⇒ derived from the request (dev/single-domain).
	OAuthCallbackURL string
	// OAuthDefaultRole is the role assigned to a user auto-created on first social
	// login. Empty falls back to SignupRole; if BOTH are empty, a brand-new social
	// email is rejected (existing users still link/login) — auto-provision is
	// opt-in, like signup.
	OAuthDefaultRole string
	// OAuthSuccessRedirect, when set, makes the callback 302 to "<url>#token=<jwt>"
	// instead of returning JSON (convenient for a browser SPA). Empty ⇒ JSON.
	OAuthSuccessRedirect string

	// --- AUTH-MFA-V1: TOTP multi-factor ---
	// MFAKey is the key material that ENCRYPTS the TOTP secret at rest (AES-256-GCM
	// over SHA-256(MFAKey)). Empty falls back to JWTSecret. A TOTP secret must be
	// recoverable (the server re-derives codes), so it is encrypted, not hashed.
	MFAKey string
	// MFAIssuer is the issuer label shown in the authenticator app (otpauth URI).
	// Empty ⇒ "Appximo".
	MFAIssuer string
	// contains filtered or unexported fields
}

Config configures a Service. JWTSecret and SignupRole are the levers a deployer sets; the rest have sane defaults.

type OAuthProviderConfig

type OAuthProviderConfig struct {
	ClientID     string
	ClientSecret string
}

OAuthProviderConfig holds one provider's client credentials. A provider with an empty ClientID is simply NOT offered (the engine never fails to boot over an unconfigured provider).

type PublicUser

type PublicUser struct {
	ID            string    `json:"id"`
	Email         string    `json:"email"`
	Role          string    `json:"role"`
	EmailVerified bool      `json:"email_verified"`
	CreatedAt     time.Time `json:"created_at"`
}

PublicUser is the user shape returned to clients — NEVER the password hash.

type SecretCipher

type SecretCipher = secretCipher

SecretCipher encrypts/decrypts a recoverable secret (e.g. a TOTP secret) at rest with AES-256-GCM. It is an alias of the in-package cipher so the platform admin store reuses the identical construction.

func NewSecretCipher

func NewSecretCipher(keyMaterial string) (*SecretCipher, error)

NewSecretCipher builds a SecretCipher from key material (SHA-256(keyMaterial) → AES-256). Returns an error only when keyMaterial is empty.

type Service

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

Service implements the password identity core: signup, login, refresh, plus the AUTH-EMAIL-V1 reset + verification flows.

func NewService

func NewService(store *Store, cfg Config) *Service

NewService builds a Service. dummyHash is computed once (an argon2id hash of a random-ish constant); its only purpose is constant-ish login timing.

func (*Service) ConfirmMFA

func (s *Service) ConfirmMFA(ctx context.Context, tenantID, userID, code string) ([]string, error)

ConfirmMFA validates the first TOTP code against the pending secret and, on success, flips enabled=true and returns freshly generated one-time backup codes (only their hashes are stored). Requiring a valid code before enabling means a mis-scanned secret can never lock the user out.

func (*Service) ConfirmReset

func (s *Service) ConfirmReset(ctx context.Context, tenantID, plainToken, newPassword string) error

ConfirmReset consumes a reset token and sets the user's new password. The token is consumed atomically with the password update, and ALL other still-pending reset tokens for that user are invalidated in the same tx (a completed reset kills every outstanding reset link). The new password must meet the minimum length.

func (*Service) ConfirmVerify

func (s *Service) ConfirmVerify(ctx context.Context, tenantID, plainToken string) error

ConfirmVerify consumes a verification token and marks the user's email verified. The token is single-use and consumed atomically with the flag flip.

func (*Service) DisableMFA

func (s *Service) DisableMFA(ctx context.Context, tenantID, userID, code, password string) error

DisableMFA turns MFA off, but ONLY when the caller proves a second factor (a current TOTP code OR a backup code) or the account password — never the session JWT alone, so a stolen access token cannot strip the protection.

func (*Service) EnableMFA

func (s *Service) EnableMFA(ctx context.Context, tenantID, userID string) (secret, uri string, err error)

EnableMFA begins enrollment for an (already authenticated) user: it generates a TOTP secret, stores it ENCRYPTED with enabled=false, and returns the secret + otpauth URI ONCE for the user to load into their authenticator app. MFA is not active until ConfirmMFA proves a working code. The account label in the URI is the user's email (cosmetic — shown in the app).

func (*Service) Login

func (s *Service) Login(ctx context.Context, tenantID, email, password string) (AuthResult, error)

Login verifies credentials and returns a token. It is uniform across "unknown email" and "wrong password" (same ErrInvalidCredentials, same ~timing) so a caller cannot enumerate which emails exist. Throttled per (tenant, email).

func (*Service) MFAVerify

func (s *Service) MFAVerify(ctx context.Context, tenantID, mfaToken, code string) (AuthResult, error)

MFAVerify completes a login's second factor: it validates the intermediate mfa_token, then accepts either a current TOTP code (±1 step) OR a one-time backup code (consumed). On success it mints the FINAL engine JWT. Throttled per (tenant, user) — a 6-digit code is brute-forceable without a limit.

func (*Service) OAuthAuthCodeURL

func (s *Service) OAuthAuthCodeURL(tenantID, provider, redirectURI string) (string, error)

OAuthAuthCodeURL builds the provider authorize URL for the tenant, embedding a freshly-signed state. redirectURI MUST equal the one used at the callback's token exchange (OAuth requires it to match).

func (*Service) OAuthCallback

func (s *Service) OAuthCallback(ctx context.Context, provider, code, stateStr, redirectURI string) (AuthResult, error)

OAuthCallback validates the state, exchanges the code, resolves (or creates) the user, and returns an engine-valid JWT. The tenant comes from the SIGNED STATE, never from the request Host. redirectURI must match the one used at initiate.

func (*Service) OAuthEnabled

func (s *Service) OAuthEnabled() bool

OAuthEnabled reports whether any OAuth provider is configured.

func (*Service) OAuthProviderConfigured

func (s *Service) OAuthProviderConfigured(name string) bool

OAuthProviderConfigured reports whether the named provider is offered.

func (*Service) OAuthSuccessRedirect

func (s *Service) OAuthSuccessRedirect() string

OAuthSuccessRedirect returns the configured post-login redirect URL ("" → the callback returns JSON).

func (*Service) Refresh

func (s *Service) Refresh(ctx context.Context, tenantID, tokenStr string) (string, error)

Refresh re-mints a token from a still-valid one, extending exp. The JWT stays stateless (no server session): the token is validated, its tenant is checked against the request tenant (no cross-tenant refresh), and a fresh token with the same identity/role is issued. A token whose user was later deleted keeps working until exp — the standard stateless-JWT trade-off, documented.

func (*Service) RequestReset

func (s *Service) RequestReset(ctx context.Context, tenantID, email, linkBase string) error

RequestReset issues a password-reset link for email IF a user exists. Uniform regardless of existence (anti-enumeration): always returns nil. Throttled.

func (*Service) RequestVerify

func (s *Service) RequestVerify(ctx context.Context, tenantID, email, linkBase string) error

RequestVerify issues an email-verification link for email, IF a user with that email exists and is not already verified. It is uniform regardless of existence (anti-enumeration): it ALWAYS returns nil to the caller (a real send happens only when the user exists), so a client cannot probe which emails are registered. Throttled per (tenant, email) to blunt email-spam.

func (*Service) Router

func (s *Service) Router() http.Handler

Router returns the /auth subrouter (mounted at /auth by the engine). The routes are UNAUTHENTICATED by design — signup/login happen BEFORE a token exists — but tenant-aware: every handler resolves the tenant from the Host subdomain (TenantMiddleware ran upstream), so a user is always created and authenticated within ONE tenant's schema. These paths sit outside /api/, so the RBAC middleware passes them through; the engine adds "/auth/" to the JWT skip list so no Bearer token is required to reach them.

func (*Service) Signup

func (s *Service) Signup(ctx context.Context, tenantID, email, password string) (AuthResult, error)

Signup creates a user in the tenant's schema and returns it plus a token. The role is ALWAYS the configured SignupRole (client input ignored). Email is normalized (trim + lowercase). A duplicate within the tenant → ErrEmailTaken; the same email in another tenant is independent and succeeds (the advantage).

func (*Service) SignupEnabled

func (s *Service) SignupEnabled() bool

SignupEnabled reports whether public signup is configured.

type Store

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

Store persists per-tenant users. It uses the engine's shared pool directly and keys every statement to tenant_<id> via a sanitized identifier — the same schema-per-tenant isolation pkg/files uses for blob metadata. The table DDL is run once per tenant per process (lazy, cached in `ensured`).

func NewStore

func NewStore(pool *pgxpool.Pool) *Store

NewStore builds a Store over the engine pool.

func (*Store) CountUsers

func (s *Store) CountUsers(ctx context.Context, tenantID string) (int, error)

CountUsers returns the number of users in the tenant. Used for cheap tenant metadata in the admin tenant list.

func (*Store) Create

func (s *Store) Create(ctx context.Context, tenantID, email, passwordHash, role string) (User, error)

Create inserts a new user in the tenant's schema. email is stored as given (trimmed/lowercased by the caller); a duplicate (case-insensitive) within the SAME tenant returns ErrEmailTaken. The returned User has no PasswordHash set (the caller already has it; it is never round-tripped out).

func (*Store) CreateUserWithRole

func (s *Store) CreateUserWithRole(ctx context.Context, tenantID, email, passwordHash, role string) (User, error)

CreateUserWithRole creates a user with an explicit role and a pre-hashed password (the admin chooses the role, unlike public signup). It returns ErrEmailTaken on a duplicate within the tenant. A thin wrapper over Create so the admin API expresses intent clearly.

func (*Store) DeleteUser

func (s *Store) DeleteUser(ctx context.Context, tenantID, id string) error

DeleteUser removes a user from the tenant. Returns ErrUserNotFound when no row matches. (The user's stateless JWTs remain valid until exp — the documented stateless-JWT trade-off, same as a deleted user during Refresh.)

func (*Store) GetByEmail

func (s *Store) GetByEmail(ctx context.Context, tenantID, email string) (User, error)

GetByEmail looks a user up by email (case-insensitive) within the tenant's schema. It returns ErrUserNotFound when no row matches. Because the query is scoped to tenant_<id>, a tenant can never resolve another tenant's user — the isolation that makes a cross-tenant login structurally impossible.

func (*Store) GetByID

func (s *Store) GetByID(ctx context.Context, tenantID, id string) (User, error)

GetByID loads a user by id within the tenant (used after an identity match).

func (*Store) GetIdentityUserID

func (s *Store) GetIdentityUserID(ctx context.Context, tenantID, provider, providerUserID string) (userID string, found bool, err error)

GetIdentityUserID resolves (provider, providerUserID) to the linked user id within the tenant. found is false when no identity is linked yet.

func (*Store) InsertUserTx

func (s *Store) InsertUserTx(ctx context.Context, tx pgx.Tx, tenantID, email, passwordHash, role string) (User, error)

InsertUserTx inserts a user with the given (already argon2id-hashed) password on the CALLER's transaction — the seam the library's Ctx.CreateUser uses so the user commits or rolls back atomically with the handler's other writes. An empty passwordHash creates an invitation-style user that cannot password-login until a reset sets one (same contract as an OAuth-created user); email_verified starts false (nothing verified it). The caller validates email (ValidateEmail) and role (schema RBAC) BEFORE calling — this method only persists. A duplicate email within the tenant returns ErrEmailTaken.

func (*Store) ListUsers

func (s *Store) ListUsers(ctx context.Context, tenantID string) ([]User, error)

ListUsers returns every user in the tenant (newest first), without password hashes. Intended for the admin API's user list.

func (*Store) MFAEnabled

func (s *Store) MFAEnabled(ctx context.Context, tenantID, userID string) (bool, error)

MFAEnabled reports whether the user has CONFIRMED MFA — the one cheap query the login path runs (only after a password verifies) to decide on a second factor.

func (*Store) SetUserSuspended

func (s *Store) SetUserSuspended(ctx context.Context, tenantID, id string, suspended bool) error

SetUserSuspended toggles a user's suspended flag (an administrative lockout enforced at login). Returns ErrUserNotFound when no row matches.

func (*Store) UpdateUserRole

func (s *Store) UpdateUserRole(ctx context.Context, tenantID, id, role string) error

UpdateUserRole sets a user's role. The new role is NOT validated against the schema RBAC here (the admin API does that before calling, so the error message can list the valid roles); the Store only persists. Returns ErrUserNotFound when no row matches in the tenant.

type User

type User struct {
	ID            string
	Email         string
	PasswordHash  string
	Role          string
	EmailVerified bool
	// Suspended, when true, blocks login (an administrative lockout managed by the
	// admin API — pkg/platformadmin). Only GetByEmail reads it (login is the only
	// path that enforces it); Create/GetByID leave it zero (false) as they never
	// need it. The column is added idempotently in ensure.
	Suspended bool
	CreatedAt time.Time
	UpdatedAt time.Time
}

User is a stored identity. PasswordHash is never serialized to a client (no json tag exposure — handlers build their own response shape) and never logged.

Jump to

Keyboard shortcuts

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