Documentation
¶
Index ¶
- Constants
- func AccessLog(log *slog.Logger, trustedProxies ...*net.IPNet) func(http.Handler) http.Handler
- func Authenticate(cfg *AuthConfig, sessions SessionStore, pats PATLookup) func(http.Handler) http.Handler
- func BodyLimit(n int64) func(http.Handler) http.Handler
- func CORS(allowedOrigins []string, allowCredentials bool) func(http.Handler) http.Handler
- func CSRFConditional(cfg *CSRFConfig, secure bool) func(http.Handler) http.Handler
- func CSRFMaskedToken(r *http.Request) string
- func ClientIP(r *http.Request, trustedProxies ...*net.IPNet) string
- func ClientMetadata(next http.Handler) http.Handler
- func ClientMetadataFrom(ctx context.Context) (platform, version, os string)
- func ConfigHandler(d ConfigDeps) http.HandlerFunc
- func DecodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool
- func DeriveCSRFKey(signingSecret []byte) []byte
- func ErrConflict(w http.ResponseWriter, msg string)
- func ErrForbidden(w http.ResponseWriter, msg string)
- func ErrInternal(w http.ResponseWriter)
- func ErrNotFound(w http.ResponseWriter, msg string)
- func ErrPayloadTooLarge(w http.ResponseWriter, msg string)
- func ErrReauthRequired(w http.ResponseWriter, maxAge time.Duration, confirmedAt time.Time)
- func ErrReauthRequiredFactor(w http.ResponseWriter, maxAge time.Duration, confirmedAt time.Time, ...)
- func ErrTimeout(w http.ResponseWriter)
- func ErrUnauthenticated(w http.ResponseWriter, msg string)
- func ErrUnavailable(w http.ResponseWriter, msg string)
- func ErrValidation(w http.ResponseWriter, msg string)
- func HashIP(ip, secret string) string
- func HashToken(token string) string
- func MintToken() (string, error)
- func P(fn func(w http.ResponseWriter, r *http.Request, p *Principal)) http.HandlerFunc
- func ParseOrigins(raw string) []string
- func ParseTrustedProxies(raw string) []*net.IPNet
- func PoolStats(pool *pgxpool.Pool) (acquired, idle, total int32)
- func PrincipalHolder() func(http.Handler) http.Handler
- func RateLimit(l Limiter, dimension string, perMinute int, keyFn func(r *http.Request) string) func(http.Handler) http.Handler
- func Recover(log *slog.Logger) func(http.Handler) http.Handler
- func RequestID(r *http.Request) string
- func RequestIDFromCtx(ctx context.Context) string
- func RequestIDMiddleware(next http.Handler) http.Handler
- func RequireAuth(next http.Handler) http.Handler
- func RequireHuman(next http.HandlerFunc) http.HandlerFunc
- func RequireRecentAuth(maxAge time.Duration) func(http.Handler) http.Handler
- func RequireRecentMFA(maxAge time.Duration) func(http.Handler) http.Handler
- func RequireScope(required ...string) func(http.Handler) http.Handler
- func RequireTransportKind(cfg *AuthConfig, kinds ...TransportKind) func(http.Handler) http.Handler
- func RequireWorkspaceScope() func(http.Handler) http.Handler
- func SecurityHeaders(next http.Handler) http.Handler
- func Timeout(d time.Duration) func(http.Handler) http.Handler
- func TimeoutExcept(d time.Duration, exemptPrefixes ...string) func(http.Handler) http.Handler
- func WithPrincipal(ctx context.Context, p *Principal) context.Context
- func WriteErr(w http.ResponseWriter, err error)
- func WriteError(w http.ResponseWriter, status int, code, message string, details any)
- func WriteJSON(w http.ResponseWriter, status int, v any)
- type AuthConfig
- type CSRFConfig
- type CheckFunc
- type ConfigDeps
- type CredentialScope
- type Error
- func NewConflict(msg string) *Error
- func NewError(status int, code, message string) *Error
- func NewForbidden(msg string) *Error
- func NewNotFound(msg string) *Error
- func NewQuotaExceeded(msg string) *Error
- func NewRateLimited(msg string) *Error
- func NewUnauthenticated(msg string) *Error
- func NewValidation(msg string) *Error
- type ErrorBody
- type Factor
- type FailOpenLimiter
- type Health
- type Limiter
- type Mux
- type PATLookup
- type Principal
- type PrincipalCell
- type ProjectScope
- type RouteInfo
- type Router
- type SessionCreate
- type SessionStore
- type Source
- type TransportKind
Constants ¶
const ( HeaderClientPlatform = "X-Client-Platform" HeaderClientVersion = "X-Client-Version" HeaderClientOS = "X-Client-OS" )
const ( CodeUnauthenticated = "E_UNAUTHENTICATED" CodeForbidden = "E_FORBIDDEN" CodeNotFound = "E_NOT_FOUND" CodeValidation = "E_VALIDATION" CodeConflict = "E_CONFLICT" CodeRateLimited = "E_RATE_LIMITED" CodeQuotaExceeded = "E_QUOTA_EXCEEDED" CodeInternal = "E_INTERNAL" CodeTimeout = "E_TIMEOUT" CodePayloadTooLarge = "E_PAYLOAD_TOO_LARGE" // CodeReauthRequired is the step-up challenge (RequireRecentAuth): the // caller is authenticated but the password confirmation is stale — confirm // via POST /auth/confirm-password, then retry. 403, never 401. CodeReauthRequired = "E_REAUTH_REQUIRED" // CodeAuthTransportConflict rejects mixed-transport requests (session // cookie AND bearer credential on one request — or an ambiguous duplicate // of either): which credential authenticated and which transport the // gates inspected must never diverge (GitLab #22435 class). 401, and // logged as a security signal, never silently disambiguated. CodeAuthTransportConflict = "E_AUTH_TRANSPORT_CONFLICT" )
const ClientUnknown = "unknown"
const DefaultPATPrefix = "tk_"
DefaultPATPrefix is the canonical PAT token prefix: webx parses the Bearer token with it, wsx.DefaultPATPrefix aliases it for WebSocket first-frame auth, and identity/domain keeps a mirrored constant (domain purity — pure stdlib, no framework import). internal/arch's parity test pins the three together; changing the prefix means changing them in lockstep and handling already-stored tokens.
const HeaderRequestID = "X-Request-Id"
Variables ¶
This section is empty.
Functions ¶
func Authenticate ¶
func Authenticate(cfg *AuthConfig, sessions SessionStore, pats PATLookup) func(http.Handler) http.Handler
func CSRFConditional ¶
func CSRFMaskedToken ¶
func ClientMetadataFrom ¶
func ConfigHandler ¶
func ConfigHandler(d ConfigDeps) http.HandlerFunc
func DecodeJSON ¶
func DeriveCSRFKey ¶
func ErrConflict ¶
func ErrConflict(w http.ResponseWriter, msg string)
func ErrForbidden ¶
func ErrForbidden(w http.ResponseWriter, msg string)
func ErrInternal ¶
func ErrInternal(w http.ResponseWriter)
func ErrNotFound ¶
func ErrNotFound(w http.ResponseWriter, msg string)
func ErrPayloadTooLarge ¶
func ErrPayloadTooLarge(w http.ResponseWriter, msg string)
func ErrReauthRequired ¶ added in v0.4.0
ErrReauthRequired writes the step-up challenge: RFC 9470's insufficient_user_authentication semantics adapted to ploykit's cookie + error-envelope shape. 403 (not 401): the caller IS authenticated, only the assurance is stale, and a 401 would wrongly steer SPAs to full re-login.
func ErrReauthRequiredFactor ¶ added in v0.5.0
func ErrReauthRequiredFactor(w http.ResponseWriter, maxAge time.Duration, confirmedAt time.Time, factor Factor)
ErrReauthRequiredFactor is ErrReauthRequired with the stale factor named in the details: same 403 E_REAUTH_REQUIRED envelope, details.factor tells the client which proof to present.
func ErrTimeout ¶
func ErrTimeout(w http.ResponseWriter)
func ErrUnauthenticated ¶
func ErrUnauthenticated(w http.ResponseWriter, msg string)
func ErrUnavailable ¶
func ErrUnavailable(w http.ResponseWriter, msg string)
func ErrValidation ¶
func ErrValidation(w http.ResponseWriter, msg string)
func P ¶
func P(fn func(w http.ResponseWriter, r *http.Request, p *Principal)) http.HandlerFunc
func ParseOrigins ¶
func ParseTrustedProxies ¶
func RequestIDFromCtx ¶
func RequireHuman ¶
func RequireHuman(next http.HandlerFunc) http.HandlerFunc
func RequireRecentAuth ¶ added in v0.4.0
RequireRecentAuth guards sensitive operations behind a fresh password confirmation (step-up / sudo mode, the Laravel password.confirm / GitHub sudo-mode pattern). Mount it after Authenticate. Only interactive sessions pass: a session whose PasswordConfirmedAt is older than maxAge — or never set — fails with the two-phase reauthentication protocol (403 E_REAUTH_REQUIRED + details.max_age_seconds): the client re-verifies the password via POST /auth/confirm-password and retries the original request (docs/api-conventions.md §step-up). Machine callers (PAT / system) can never satisfy step-up and get a plain 403 — they cannot reauthenticate interactively, so a scoped PAT must not reach actions reserved for a present human.
The window is a product policy decision (GitHub uses 2h, Laravel 3h defaults): pick it per mount site, there is deliberately no package default.
This is the static, route-level policy path. authorization/'s Challenge outcome covers the computed, catalog-driven escalation for strong-profile consumers — do not mount both on the same operation (see the step-up section of authorization/README.md).
func RequireRecentMFA ¶ added in v0.5.0
RequireRecentMFA is RequireRecentAuth's second-factor twin: it guards AAL2-grade operations behind a fresh MFA proof (TOTP / backup code via POST /auth/verify-mfa). Mount it after Authenticate. Only interactive sessions pass: a session whose MFAConfirmedAt is older than maxAge — or never set — fails with the two-phase challenge (403 E_REAUTH_REQUIRED + details.max_age_seconds + details.factor="mfa"); machine callers (PAT / system) get a plain 403 — they can never complete an interactive MFA proof (ADR 0011 semantics extended to MFA, ADR 0013 batch 2).
The window is product policy — pick it per mount site, there is no package default. This is the static road; the computed, catalog-driven form is authorization/'s recent_auth: password_and_mfa — do not mount both on the same operation.
func RequireScope ¶ added in v0.4.0
RequireScope closes the PAT scope-enforcement gap on endpoints that do NOT go through authz (machine-to-machine mounts such as MCP tools, OpenAPI servers, webhook receivers). authz.CanIn already enforces CredentialScope.Permissions when the product calls it; this middleware is the equivalent default for handlers that build their own actor straight from the Principal — without it, any PAT passes regardless of its declared scope (field report: risk-engine-server issue #12, a scoped PAT invoking an unrestricted MCP tool succeeded where it should have been rejected).
Semantics:
- Non-PAT principals (sessions) pass untouched — browser sessions are not scope-bounded; authorization stays with authz/role checks.
- PATs whose scope carries no Permissions list are unrestricted by design (AllowsPermission: nil = allow-all) and pass.
- A PAT with a Permissions list passes only if some declared permission shares the domain of at least one required permission (permDomain: "post:submit" and "post:*" both cover domain "post").
Mount it right after Authenticate on machine-facing routes:
mux.Handle("POST /mcp", Authenticate(...)(RequireScope("post:submit", "post:publish")(h)))
Workspace bounding (Scope.WorkspaceIDs) is checked by RequireWorkspaceScope when the workspace id is known at mount time; for request-derived workspace ids keep using authz.CanIn inside the handler.
func RequireTransportKind ¶ added in v0.5.0
func RequireTransportKind(cfg *AuthConfig, kinds ...TransportKind) func(http.Handler) http.Handler
RequireTransportKind rejects requests whose transport is not one of kinds. Mount it before any handler logic runs — a transport violation terminates the request before authentication, authorization, or CSRF can form an opinion on the "wrong" credential (that ordering is the GitLab-#22435 lesson).
- mixed transport → 401 E_AUTH_TRANSPORT_CONFLICT always (a security signal worth its own log line, never silently disambiguated);
- no credential → 401 E_UNAUTHENTICATED;
- a present-but-not-allowed transport → 403 E_FORBIDDEN (the caller authenticated, this transport may not perform the operation).
Typical mount: RequireTransportKind(&cfg, TransportBearer) in front of a machine-only endpoint. Session-only and multi-transport routes keep their ordinary auth middleware — this matcher is opt-in per operation class.
func RequireWorkspaceScope ¶ added in v0.4.0
RequireWorkspaceScope bounds a PAT to its declared workspace list when the endpoint serves exactly one fixed workspace context (request-derived contexts belong in the handler via authz.CanIn, which also checks WorkspaceIDs). Non-PAT principals pass untouched.
func TimeoutExcept ¶
func WriteErr ¶
func WriteErr(w http.ResponseWriter, err error)
func WriteError ¶
func WriteError(w http.ResponseWriter, status int, code, message string, details any)
Types ¶
type AuthConfig ¶
type AuthConfig struct {
CookieName string
CookieDomain string
Secure bool
SessionTTL time.Duration
AbsoluteTTL time.Duration
IPHashSecret string
PATPrefix string
}
func DefaultAuthConfig ¶
func DefaultAuthConfig() AuthConfig
DefaultAuthConfig returns the shared defaults. Secure defaults to **false**: a production-true default made every local-HTTP / intranet deployment fail login with an opaque CSRF error (the Secure cookie is never sent back over plain HTTP — two layers away from the root cause, see aiblog/risk-engine field reports). Production deployments must opt in explicitly:
cfg := webx.DefaultAuthConfig() cfg.Secure = env == "production"
example wires it to SECURE_COOKIE the same way.
func (*AuthConfig) ClassifyTransport ¶ added in v0.5.0
func (c *AuthConfig) ClassifyTransport(r *http.Request) TransportKind
ClassifyTransport inspects the raw request (no store lookups) and names its auth transport. Strictness is the point:
- exactly one session cookie → session_cookie; more than one cookie with the configured name (domain/path shadowing) is ambiguous → mixed;
- exactly one `Authorization: Bearer <prefix…>` header (the configured PAT prefix — foreign bearer schemes are not our credentials) → bearer; several Authorization headers → mixed;
- cookie AND bearer present → mixed.
Authenticate resolves sessions and PATs through this single classification, so "which credential authenticated" and "which transport the gate saw" can never diverge.
func (*AuthConfig) ClearSessionCookie ¶
func (c *AuthConfig) ClearSessionCookie(w http.ResponseWriter)
func (*AuthConfig) SetSessionCookie ¶
func (c *AuthConfig) SetSessionCookie(w http.ResponseWriter, token string, exp time.Time)
type CSRFConfig ¶
type CSRFConfig struct {
Key []byte
// TrustedOrigins lists host[:port] entries gorilla/csrf accepts as
// cross-origin submitters. Matching is exact, port included.
TrustedOrigins []string
// TrustLocalhostAnyPort is a development convenience: when set, the
// loopback family (localhost, 127.0.0.1, [::1]) is trusted on ANY port,
// so a dev server drifting off its default port (vite 5173 -> 5176 when
// 5173 is taken) does not turn every write into an opaque 403. Leave it
// off in production; production must enumerate exact origins.
TrustLocalhostAnyPort bool
ExemptPrefixes []string
// MaxAge is the CSRF cookie lifetime in seconds, passed through to
// gorilla/csrf verbatim (orkome pack-rollout addendum). Zero value keeps the framework's
// historical behavior (gorilla's 12h default); a product whose session
// TTL exceeds 12h must raise this or long-idle users hit "every write
// 403s" until the next /config refetch (the cookie dies before the
// session does) — align it with AuthConfig.SessionTTL. Negative means a
// session cookie (gorilla semantics). The zero-value-defaults-to-12h
// reading is deliberate: an unset config must not change existing
// deployments' cookie behavior.
MaxAge int
PATPrefix string
// SessionCookieName is the auth session cookie's name — the CSRF skip is
// a clean bearer transport only; a request that also carries this cookie
// is mixed and falls through to the CSRF check. Wire it to
// AuthConfig.CookieName (empty = never treat requests as cookie-carrying).
SessionCookieName string
}
type ConfigDeps ¶
type CredentialScope ¶
type CredentialScope struct {
WorkspaceIDs []string `json:"workspace_ids,omitempty"`
Permissions []string `json:"permissions,omitempty"`
}
func (*CredentialScope) Allows ¶ added in v0.4.0
func (s *CredentialScope) Allows(required ...string) bool
Allows reports whether the scope covers ALL of the required permissions (same per-permission semantics as AllowsPermission; nil Permissions = unrestricted). The default enforcement gate for machine-facing mounts: webx.RequireScope(required...) is exactly `p.Scope.Allows(required...)`.
func (*CredentialScope) AllowsPermission ¶
func (s *CredentialScope) AllowsPermission(perm string) bool
func (*CredentialScope) AllowsWorkspace ¶
func (s *CredentialScope) AllowsWorkspace(workspaceID string) bool
type Error ¶
func NewConflict ¶
func NewForbidden ¶
func NewNotFound ¶
func NewQuotaExceeded ¶
func NewRateLimited ¶
func NewUnauthenticated ¶
func NewValidation ¶
type Factor ¶ added in v0.5.0
type Factor string
Factor names the stale step-up fact, so clients know which confirmation endpoint clears the challenge (confirm-password vs verify-mfa).
type FailOpenLimiter ¶
type FailOpenLimiter struct{}
type Health ¶
type Health struct {
// contains filtered or unexported fields
}
func (*Health) Liveness ¶
func (h *Health) Liveness() http.HandlerFunc
func (*Health) Readiness ¶
func (h *Health) Readiness() http.HandlerFunc
type Mux ¶ added in v0.4.0
type Mux struct {
// contains filtered or unexported fields
}
Mux wraps *http.ServeMux and records every registered pattern, giving products route introspection the std mux lacks (the chi.Walk equivalent): Routes() returns a sorted, immutable snapshot for diagnostics, route listings, and runtime↔OpenAPI contract tests. Serving behavior is the delegated std mux, unchanged.
func (*Mux) Handle ¶ added in v0.4.0
Handle records the pattern and delegates to the wrapped *http.ServeMux.
func (*Mux) HandleFunc ¶ added in v0.4.0
HandleFunc records the pattern and delegates to the wrapped *http.ServeMux.
type Principal ¶
type Principal struct {
UserID string
Email string
Name string
Source Source
SessionID string
PATID string
WorkspaceID string
Role string
Project *ProjectScope
Scope *CredentialScope
AgentID string
IsPlatformAdmin bool
ImpersonatedBy string
// PasswordConfirmedAt records when the caller last proved the password for
// this session: a password login / registration / password change births
// the session already confirmed, and POST /auth/confirm-password (step-up)
// re-stamps it later. Zero = never proved (code login, third-party login,
// impersonation, PAT). Consumed by RequireRecentAuth.
PasswordConfirmedAt time.Time
// MFAConfirmedAt records when the caller last proved a second factor for
// this session (TOTP / backup code via POST /auth/verify-mfa). Sessions
// born from a login that completed MFA start confirmed
// (SessionCreate.MFAConfirmed); password-only logins stay unconfirmed.
// Zero = no MFA proof on this session. Consumed by RequireRecentMFA.
MFAConfirmedAt time.Time
}
func PrincipalFrom ¶
func PrincipalFromRequest ¶
func (*Principal) MFAConfirmedWithin ¶ added in v0.5.0
MFAConfirmedWithin is MFAConfirmedWithin's second factor twin: it reports whether the MFA proof on this session is fresh enough for a RequireRecentMFA window of maxAge.
func (*Principal) PasswordConfirmedWithin ¶ added in v0.4.0
PasswordConfirmedWithin reports whether the password confirmation is fresh enough for a step-up window of maxAge (never-confirmed and future-dated stamps both fail).
type PrincipalCell ¶
type PrincipalCell struct {
// contains filtered or unexported fields
}
func (*PrincipalCell) Get ¶
func (c *PrincipalCell) Get() *Principal
func (*PrincipalCell) Set ¶
func (c *PrincipalCell) Set(p *Principal)
type ProjectScope ¶
type RouteInfo ¶ added in v0.4.0
type RouteInfo struct {
// Method is the leading HTTP method of the pattern ("GET", "POST", ...).
// It is "" when the pattern carries no method prefix (mounts, catch-alls,
// subtree handlers) — by convention those are not endpoints.
Method string
// Path is the pattern minus the method prefix, kept literal: wildcards
// like {id} are preserved exactly as registered.
Path string
// Pattern is the raw pattern as registered (method prefix included).
Pattern string
}
RouteInfo describes one registered route pattern.
type Router ¶ added in v0.4.0
type Router interface {
Handle(pattern string, h http.Handler)
HandleFunc(pattern string, f func(http.ResponseWriter, *http.Request))
}
Router is the route-registration surface every framework Mount function accepts. *http.ServeMux satisfies it, so products keeping a std mux keep compiling unchanged; passing a *Mux additionally records the route set for runtime introspection (see Mux).
type SessionCreate ¶ added in v0.4.0
type SessionCreate struct {
UserID string
IPHash string
UserAgent string
// PasswordConfirmed marks a session born right after an explicit password
// proof (password login, registration, password change). Such sessions
// start with PasswordConfirmedAt set and satisfy RequireRecentAuth
// immediately; code/third-party logins stay unconfirmed until the caller
// completes a step-up (POST /auth/confirm-password).
PasswordConfirmed bool
// MFAConfirmed marks a session born from a login that completed the
// second factor (TOTP / backup code). Such sessions start with
// MFAConfirmedAt set and satisfy RequireRecentMFA immediately;
// password-only logins stay MFA-unconfirmed until the caller completes
// POST /auth/verify-mfa. Password-only and MFA-only births are both
// expressible — the two stamps are independent facts (ADR 0013 batch 2:
// a login completing MFA stamps both, a password-only login only the
// password fact).
MFAConfirmed bool
Now time.Time
}
SessionCreate carries the inputs of SessionStore.CreateSession.
type SessionStore ¶
type SessionStore interface {
CreateSession(ctx context.Context, in SessionCreate) (token string, exp time.Time, err error)
VerifySession(ctx context.Context, token string, now time.Time) (*Principal, error)
RenewSession(ctx context.Context, token string, now time.Time) (exp time.Time, renewed bool, err error)
RevokeSession(ctx context.Context, token string) error
}
type TransportKind ¶ added in v0.5.0
type TransportKind string
TransportKind is the authentication transport a request arrived on — the per-operation transport contract of ADR 0013 batch 3 (G4): the operation's declared auth_method is only as strong as the transport it accepts, and a request carrying TWO credentials must never be resolved by picking one silently (GitLab #22435 class: CSRF decisions keyed off one credential while the other authenticates).
The kinds are deliberately neutral — webx never imports authorization/ (arch A6 cuts both ways); the catalog auth_method → kind mapping is five lines at the composition root.
const ( // TransportNone: no credential on the wire. TransportNone TransportKind = "none" // TransportSessionCookie: exactly one session cookie, no bearer header. TransportSessionCookie TransportKind = "session_cookie" // TransportBearer: exactly one well-formed Authorization: Bearer header, // no session cookie. TransportBearer TransportKind = "bearer" // TransportMixed: both credentials (or an ambiguous duplicate of one) on // the same request — always a rejection, never resolved by precedence. TransportMixed TransportKind = "mixed" )