webx

package
v0.5.0 Latest Latest
Warning

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

Go to latest
Published: Oct 12, 2026 License: MIT Imports: 22 Imported by: 0

Documentation

Index

Constants

View Source
const (
	HeaderClientPlatform = "X-Client-Platform"
	HeaderClientVersion  = "X-Client-Version"
	HeaderClientOS       = "X-Client-OS"
)
View Source
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"
	CodeUnavailable     = "E_UNAVAILABLE"

	// 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"
)
View Source
const ClientUnknown = "unknown"
View Source
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.

View Source
const HeaderRequestID = "X-Request-Id"

Variables

This section is empty.

Functions

func AccessLog

func AccessLog(log *slog.Logger, trustedProxies ...*net.IPNet) func(http.Handler) http.Handler

func Authenticate

func Authenticate(cfg *AuthConfig, sessions SessionStore, pats PATLookup) func(http.Handler) http.Handler

func BodyLimit

func BodyLimit(n int64) func(http.Handler) http.Handler

func CORS

func CORS(allowedOrigins []string, allowCredentials bool) func(http.Handler) http.Handler

func CSRFConditional

func CSRFConditional(cfg *CSRFConfig, secure bool) func(http.Handler) http.Handler

func CSRFMaskedToken

func CSRFMaskedToken(r *http.Request) string

func ClientIP

func ClientIP(r *http.Request, trustedProxies ...*net.IPNet) string

func ClientMetadata

func ClientMetadata(next http.Handler) http.Handler

func ClientMetadataFrom

func ClientMetadataFrom(ctx context.Context) (platform, version, os string)

func ConfigHandler

func ConfigHandler(d ConfigDeps) http.HandlerFunc

func DecodeJSON

func DecodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool

func DeriveCSRFKey

func DeriveCSRFKey(signingSecret []byte) []byte

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

func ErrReauthRequired(w http.ResponseWriter, maxAge time.Duration, confirmedAt time.Time)

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 HashIP

func HashIP(ip, secret string) string

func HashToken

func HashToken(token string) string

func MintToken

func MintToken() (string, error)

func P

func ParseOrigins

func ParseOrigins(raw string) []string

func ParseTrustedProxies

func ParseTrustedProxies(raw string) []*net.IPNet

func PoolStats

func PoolStats(pool *pgxpool.Pool) (acquired, idle, total int32)

func PrincipalHolder

func PrincipalHolder() func(http.Handler) http.Handler

func RateLimit

func RateLimit(l Limiter, dimension string, perMinute int, keyFn func(r *http.Request) string) func(http.Handler) http.Handler

func Recover

func Recover(log *slog.Logger) func(http.Handler) http.Handler

func RequestID

func RequestID(r *http.Request) string

func RequestIDFromCtx

func RequestIDFromCtx(ctx context.Context) string

func RequestIDMiddleware

func RequestIDMiddleware(next http.Handler) http.Handler

func RequireAuth

func RequireAuth(next http.Handler) http.Handler

func RequireHuman

func RequireHuman(next http.HandlerFunc) http.HandlerFunc

func RequireRecentAuth added in v0.4.0

func RequireRecentAuth(maxAge time.Duration) func(http.Handler) http.Handler

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

func RequireRecentMFA(maxAge time.Duration) func(http.Handler) http.Handler

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

func RequireScope(required ...string) func(http.Handler) http.Handler

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

func RequireWorkspaceScope() func(http.Handler) http.Handler

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 SecurityHeaders

func SecurityHeaders(next http.Handler) http.Handler

func Timeout

func Timeout(d time.Duration) func(http.Handler) http.Handler

func TimeoutExcept

func TimeoutExcept(d time.Duration, exemptPrefixes ...string) func(http.Handler) http.Handler

func WithPrincipal

func WithPrincipal(ctx context.Context, p *Principal) context.Context

func WriteErr

func WriteErr(w http.ResponseWriter, err error)

func WriteError

func WriteError(w http.ResponseWriter, status int, code, message string, details any)

func WriteJSON

func WriteJSON(w http.ResponseWriter, status int, v 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 CheckFunc

type CheckFunc func(ctx context.Context) error

type ConfigDeps

type ConfigDeps struct {
	OAuthProviders  func() []string
	BillingChannels func() []string

	Extra func() map[string]any
}

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

type Error struct {
	Status  int
	Code    string
	Message string
}

func NewConflict

func NewConflict(msg string) *Error

func NewError

func NewError(status int, code, message string) *Error

func NewForbidden

func NewForbidden(msg string) *Error

func NewNotFound

func NewNotFound(msg string) *Error

func NewQuotaExceeded

func NewQuotaExceeded(msg string) *Error

func NewRateLimited

func NewRateLimited(msg string) *Error

func NewUnauthenticated

func NewUnauthenticated(msg string) *Error

func NewValidation

func NewValidation(msg string) *Error

func (*Error) Error

func (e *Error) Error() string

type ErrorBody

type ErrorBody struct {
	Code    string `json:"error"`
	Message string `json:"message"`
	Details any    `json:"details,omitempty"`
}

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).

const (
	FactorPassword Factor = "password"
	FactorMFA      Factor = "mfa"
)

type FailOpenLimiter

type FailOpenLimiter struct{}

func (FailOpenLimiter) Allow

type Health

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

func NewHealth

func NewHealth() *Health

func (*Health) AddCheck

func (h *Health) AddCheck(name string, fn CheckFunc) *Health

func (*Health) Liveness

func (h *Health) Liveness() http.HandlerFunc

func (*Health) Readiness

func (h *Health) Readiness() http.HandlerFunc

type Limiter

type Limiter interface {
	Allow(ctx context.Context, key string, perMinute int) (ok bool, retryAfter time.Duration)
}

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 NewMux added in v0.4.0

func NewMux() *Mux

NewMux returns an empty recording mux.

func (*Mux) Handle added in v0.4.0

func (m *Mux) Handle(pattern string, h http.Handler)

Handle records the pattern and delegates to the wrapped *http.ServeMux.

func (*Mux) HandleFunc added in v0.4.0

func (m *Mux) HandleFunc(pattern string, f func(http.ResponseWriter, *http.Request))

HandleFunc records the pattern and delegates to the wrapped *http.ServeMux.

func (*Mux) Routes added in v0.4.0

func (m *Mux) Routes() []RouteInfo

Routes returns a sorted (by Path, then Method) immutable snapshot of the patterns registered on this Mux. Routes registered on a nested sub-mux are NOT included: only direct registrations are recorded.

func (*Mux) ServeHTTP added in v0.4.0

func (m *Mux) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP delegates to the wrapped *http.ServeMux.

type PATLookup

type PATLookup interface {
	ResolvePAT(ctx context.Context, token string, now time.Time) (*Principal, error)
}

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 PrincipalFrom(ctx context.Context) *Principal

func PrincipalFromRequest

func PrincipalFromRequest(r *http.Request) *Principal

func (*Principal) MFAConfirmedWithin added in v0.5.0

func (p *Principal) MFAConfirmedWithin(maxAge time.Duration, now time.Time) bool

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

func (p *Principal) PasswordConfirmedWithin(maxAge time.Duration, now time.Time) bool

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 ProjectScope struct {
	ID string

	Role string
}

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 Source

type Source string
const (
	SourceSession Source = "session"
	SourcePAT     Source = "pat"
	SourceSystem  Source = "system"
)

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"
)

Jump to

Keyboard shortcuts

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