Documentation
¶
Index ¶
- Variables
- func HashPassword(password string) (string, error)
- func HashToken(plain string) string
- func NewBackupCodes(n int) (display, hashes []string, err error)
- func NewTOTPSecret() (string, error)
- func NormalizeBackupCode(code string) string
- func OTPAuthURI(issuer, account, secretB32 string) string
- func TOTPCodeNow(secretB32 string) (string, error)
- func ValidateEmail(email string) (normalized string, ok bool)
- func ValidateTOTPNow(secretB32, code string) bool
- func VerifyPassword(password, encodedHash string) (bool, error)
- type AuthResult
- type Config
- type OAuthProviderConfig
- type PublicUser
- type SecretCipher
- type Service
- func (s *Service) ConfirmMFA(ctx context.Context, tenantID, userID, code string) ([]string, error)
- func (s *Service) ConfirmReset(ctx context.Context, tenantID, plainToken, newPassword string) error
- func (s *Service) ConfirmVerify(ctx context.Context, tenantID, plainToken string) error
- func (s *Service) DisableMFA(ctx context.Context, tenantID, userID, code, password string) error
- func (s *Service) EnableMFA(ctx context.Context, tenantID, userID string) (secret, uri string, err error)
- func (s *Service) Login(ctx context.Context, tenantID, email, password string) (AuthResult, error)
- func (s *Service) MFAVerify(ctx context.Context, tenantID, mfaToken, code string) (AuthResult, error)
- func (s *Service) OAuthAuthCodeURL(tenantID, provider, redirectURI string) (string, error)
- func (s *Service) OAuthCallback(ctx context.Context, provider, code, stateStr, redirectURI string) (AuthResult, error)
- func (s *Service) OAuthEnabled() bool
- func (s *Service) OAuthProviderConfigured(name string) bool
- func (s *Service) OAuthSuccessRedirect() string
- func (s *Service) Refresh(ctx context.Context, tenantID, tokenStr string) (string, error)
- func (s *Service) RequestReset(ctx context.Context, tenantID, email, linkBase string) error
- func (s *Service) RequestVerify(ctx context.Context, tenantID, email, linkBase string) error
- func (s *Service) Router() http.Handler
- func (s *Service) Signup(ctx context.Context, tenantID, email, password string) (AuthResult, error)
- func (s *Service) SignupEnabled() bool
- type Store
- func (s *Store) CountUsers(ctx context.Context, tenantID string) (int, error)
- func (s *Store) Create(ctx context.Context, tenantID, email, passwordHash, role string) (User, error)
- func (s *Store) CreateUserWithRole(ctx context.Context, tenantID, email, passwordHash, role string) (User, error)
- func (s *Store) DeleteUser(ctx context.Context, tenantID, id string) error
- func (s *Store) GetByEmail(ctx context.Context, tenantID, email string) (User, error)
- func (s *Store) GetByID(ctx context.Context, tenantID, id string) (User, error)
- func (s *Store) GetIdentityUserID(ctx context.Context, tenantID, provider, providerUserID string) (userID string, found bool, err error)
- func (s *Store) InsertUserTx(ctx context.Context, tx pgx.Tx, tenantID, email, passwordHash, role string) (User, error)
- func (s *Store) ListUsers(ctx context.Context, tenantID string) ([]User, error)
- func (s *Store) MFAEnabled(ctx context.Context, tenantID, userID string) (bool, error)
- func (s *Store) SetUserSuspended(ctx context.Context, tenantID, id string, suspended bool) error
- func (s *Store) UpdateUserRole(ctx context.Context, tenantID, id, role string) error
- type User
Constants ¶
This section is empty.
Variables ¶
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.
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.
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.
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.
var ErrIdentityExists = errors.New("userauth: identity already linked")
ErrIdentityExists signals a concurrent identity link (unique violation).
var ErrUserNotFound = errors.New("userauth: user not found")
ErrUserNotFound is returned by GetByEmail when no user matches in the tenant.
Functions ¶
func HashPassword ¶
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 ¶
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 ¶
NewBackupCodes returns n one-time recovery codes: the display forms (shown once) and their SHA-256 hashes (stored).
func NewTOTPSecret ¶
NewTOTPSecret returns a fresh random base32 TOTP secret (RFC 6238).
func NormalizeBackupCode ¶
NormalizeBackupCode strips formatting from a backup code so it verifies whether typed "ABCD-EFGH", "abcdefgh" or "abcd efgh".
func OTPAuthURI ¶
OTPAuthURI builds the otpauth://totp/… URI an authenticator app scans.
func TOTPCodeNow ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
OAuthEnabled reports whether any OAuth provider is configured.
func (*Service) OAuthProviderConfigured ¶
OAuthProviderConfigured reports whether the named provider is offered.
func (*Service) OAuthSuccessRedirect ¶
OAuthSuccessRedirect returns the configured post-login redirect URL ("" → the callback returns JSON).
func (*Service) Refresh ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 (*Store) CountUsers ¶
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 ¶
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 ¶
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 ¶
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 ¶
ListUsers returns every user in the tenant (newest first), without password hashes. Intended for the admin API's user list.
func (*Store) MFAEnabled ¶
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 ¶
SetUserSuspended toggles a user's suspended flag (an administrative lockout enforced at login). Returns ErrUserNotFound when no row matches.
func (*Store) UpdateUserRole ¶
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.