middleware

package
v1.6.0 Latest Latest
Warning

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

Go to latest
Published: Jun 29, 2026 License: AGPL-3.0 Imports: 15 Imported by: 0

Documentation

Index

Constants

View Source
const AdminAPISecretHeader = "X-Admin-Secret"

AdminAPISecretHeader carries the shared secret that authenticates the control-plane admin RPCs (AdminCreateProject and friends). A PLATFORM operator presents it to provision projects/tenants out-of-band; the value is compared in constant time against config.AdminAPISecret by the admin handler. It is intentionally distinct from the user-auth Authorization header and the X-Project-Key credential header: the admin RPCs are not user-authenticated, and the secret IS their authentication.

There is no admin-secret middleware: the secret is checked in the handler (so an unset secret yields CodeUnimplemented and a wrong one yields a denial uniformly, without a chain-position dependency). The header name lives here so the middleware and handler packages share one source of truth and tests don't hardcode the literal.

View Source
const AuthenticatedProjectHeader = "X-Authenticated-Project"

AuthenticatedProjectHeader carries the verified `project` claim from the auth middleware to the project-scope guard. The handler layer never reads it directly; it exists so the guard can cross-check the JWT-asserted project against the resolved project without re-verifying the token.

View Source
const AuthenticatedTenantHeader = "X-Authenticated-Tenant"

AuthenticatedTenantHeader carries the verified `tenant` claim from the auth middleware to the tenant-resolution middleware in mode=multi. The handler layer never reads it directly; it exists so resolution can cross-check the JWT-asserted tenant against the host-derived tenant without re-verifying the token.

View Source
const AuthenticatedUserIDHeader = "X-Authenticated-User-Id"

AuthenticatedUserIDHeader carries the verified `sub` claim from the auth middleware to the Connect handler layer.

View Source
const ClientIPHeader = "X-Client-IP"

ClientIPHeader is set by ClientIPMiddleware to the resolved client IP. Downstream handlers (audit log, rate limiter) read this header instead of X-Forwarded-For so they cannot be spoofed.

View Source
const ProjectKeyHeader = "X-Project-Key"

ProjectKeyHeader carries a project's publishable/secret credential public id. When present, it is the highest-precedence resolution source — an explicit key that does not resolve is an error, not a fallback.

Variables

View Source
var AuthExemptPaths = map[string]bool{
	"/identity.v1.IdentityService/BeginOAuthLogin": true,
	"/identity.v1.IdentityService/OAuthLogin":      true,

	"/identity.v1.IdentityService/RedeemOAuthCode": true,
	"/identity.v1.IdentityService/PasswordLogin":   true,
	"/identity.v1.IdentityService/PasswordSignup":  true,

	"/identity.v1.IdentityService/RequestEmailLoginCode": true,
	"/identity.v1.IdentityService/VerifyEmailLoginCode":  true,
	"/identity.v1.IdentityService/RequestMagicLink":      true,
	"/identity.v1.IdentityService/RedeemMagicLink":       true,
	"/identity.v1.IdentityService/RefreshToken":          true,
	"/identity.v1.IdentityService/Logout":                true,
	"/identity.v1.IdentityService/GetCurrentUser":        true,
	"/identity.v1.IdentityService/BeginPasskeyLogin":     true,
	"/identity.v1.IdentityService/CompletePasskeyLogin":  true,
	"/identity.v1.IdentityService/InitiateQrLogin":       true,
	"/identity.v1.IdentityService/PollQrLogin":           true,
	"/identity.v1.IdentityService/AcceptInvitation":      true,
	"/identity.v1.IdentityService/RequestAdminHelp":      true,
	"/identity.v1.IdentityService/VerifyTotp":            true,

	"/identity.v1.IdentityService/RequestPasswordReset": true,
	"/identity.v1.IdentityService/ConfirmPasswordReset": true,
	"/identity.v1.IdentityService/VerifyEmail":          true,

	"/identity.v1.IdentityService/ConfirmEmailChange": true,

	"/identity.v1.IdentityService/AdminCreateProject":           true,
	"/identity.v1.IdentityService/AdminCreateProjectCredential": true,
	"/identity.v1.IdentityService/AdminAddProjectAuthDomain":    true,
	"/identity.v1.IdentityService/AddProjectAuthDomain":         true,
	"/identity.v1.IdentityService/VerifyProjectAuthDomain":      true,
	"/identity.v1.IdentityService/ListProjectAuthDomains":       true,
	"/identity.v1.IdentityService/SetPrimaryAuthDomain":         true,
	"/identity.v1.IdentityService/AdminCreateTenant":            true,
	"/identity.v1.IdentityService/AdminAddTenantAdmin":          true,
	"/.well-known/jwks.json":                                    true,
	"/health":                                                   true,
	"/healthz":                                                  true,
}

AuthExemptPaths lists URL paths that do not require a valid JWT. Connect-Go uses the proto service/method as the URL path.

View Source
var ErrAllowedOriginsEmpty = errors.New("cors: no allowed origins configured")

ErrAllowedOriginsEmpty is returned by ParseAllowedOrigins when the resolved list contains no origins.

View Source
var ErrInvalidTrustedProxy = errors.New("invalid trusted proxy entry")

ErrInvalidTrustedProxy is returned by ParseTrustedProxies for entries the parser cannot interpret.

Functions

func AuthMiddleware

func AuthMiddleware(kp jwtpkg.KeyProvider, expectedTenant, expectedAudience string, requireAudience bool) func(http.Handler) http.Handler

AuthMiddleware verifies JWT Bearer tokens on non-exempt paths and injects the authenticated user ID into the X-Authenticated-User-Id request header so downstream Connect handlers can read it.

expectedTenant, when non-empty, is enforced on every verified token: tokens whose "tenant" claim does not match are rejected. Pass an empty string to disable the cross-tenant check.

expectedAudience and requireAudience are passed through to jwtpkg.VerifyAccessToken — see that function for the audience policy.

For auth-exempt paths the middleware still attempts to parse and verify a token when one is present (e.g. GetCurrentUser may optionally read the caller identity) but never rejects the request.

func CORSMiddleware

func CORSMiddleware(globalOrigins []string) func(http.Handler) http.Handler

CORSMiddleware handles CORS preflight requests and injects response headers for allowed origins. globalOrigins must be the validated output of ParseAllowedOrigins — the deployment-wide floor from GATEWAY_ALLOWED_ORIGINS. Match is exact case-sensitive on scheme+host+port.

On top of that floor, a request is matched against the resolved project's own allow-list (service.ProjectScope.CORSAllowedOrigins, set by the project resolver, already validated): an Origin in EITHER set is allowed. When no project resolves (a deployment with no control plane, or a request that resolves to no project), only the global floor applies. This middleware must run INSIDE the project resolver so the scope is present — including on the OPTIONS preflight, which carries no credentials and so relies on Host → project resolution (the resolver runs ahead of auth).

func ClientIPFromContext added in v0.6.0

func ClientIPFromContext(ctx context.Context) string

ClientIPFromContext returns the resolved client IP stored by ClientIPMiddleware. Returns "" if the middleware did not run.

func ClientIPMiddleware added in v0.6.0

func ClientIPMiddleware(trusted []*net.IPNet) func(http.Handler) http.Handler

ClientIPMiddleware resolves the client IP by walking X-Forwarded-For right-to-left, skipping any addresses that themselves come from a trusted proxy CIDR. The first untrusted address is the real client. If no XFF is present or no trusted proxies are configured, falls back to the TCP peer address.

The resolved IP is set on both the X-Client-IP header (for downstream handlers that read headers) and on the request context.

func HealthMiddleware

func HealthMiddleware(probe ReadinessProbe, next http.Handler) http.Handler

HealthMiddleware serves /livez (always 200 if the process is alive) and /readyz (200 only when probe.Ready returns nil). The legacy paths /health, /healthz, and / map to /livez for backwards compatibility.

Pass a nil probe to disable readiness checks (the endpoint then always returns 200, useful for tests).

func JWKSMiddleware

func JWKSMiddleware(kp jwtpkg.KeyProvider) func(http.Handler) http.Handler

JWKSMiddleware serves the /.well-known/jwks.json endpoint from the supplied jwtpkg.KeyProvider. The response contains the RSA public keys for every key the provider publishes so that third-party services can verify tokens without sharing a secret.

func LoggingMiddleware

func LoggingMiddleware(logger *zap.Logger) func(http.Handler) http.Handler

LoggingMiddleware logs every request's method, path, response status code, duration, and remote address using the provided zap logger. When a recording span is attached to the request context (either because the otelconnect interceptor created one or because an upstream proxy forwarded W3C TraceContext headers), the trace id is included so deployers can pivot from a log line to the full trace.

func MetricsMiddleware added in v0.7.1

func MetricsMiddleware(m *RPCMetrics) func(http.Handler) http.Handler

MetricsMiddleware records the RED counters for each Connect RPC. Non-Connect paths (/metrics, /healthz, /.well-known/jwks.json) are passed through unmeasured — they aren't RPCs and would pollute the label cardinality.

func NewProjectResolver added in v0.16.0

func NewProjectResolver(defaultProjectID, defaultScopeID, defaultPrimaryAuthDomain string, resolver service.ProjectResolver, logger *zap.Logger) func(http.Handler) http.Handler

NewProjectResolver builds the middleware. defaultProjectID / defaultScopeID are the default project's id and storage scope (the configured GATEWAY_DEFAULT_PROJECT_ID / GATEWAY_DEFAULT_TENANT_ID); defaultPrimaryAuthDomain is the default project's primary serving hostname (the first GATEWAY_DEFAULT_PROJECT_AUTH_DOMAINS entry), carried on the default-pin scope so branded links work zero-config without a per-request lookup. resolver may be nil (drivers without a control plane). When there is nothing to do — no resolver and no default project — the returned middleware is a no-op pass-through.

func NewProjectScopeGuard added in v0.17.0

func NewProjectScopeGuard() func(http.Handler) http.Handler

NewProjectScopeGuard rejects a request whose access-token `project` claim disagrees with the project resolved for the request — a token minted under project A replayed against a request resolved to project B (cross-project token reuse). It is the consumer of the project claim, the project counterpart to the tenant resolver's host/JWT cross-check.

It must run AFTER the auth middleware (which surfaces the verified project as AuthenticatedProjectHeader) and AFTER project resolution (which sets the request's ProjectScope). When either side is absent it is a pass-through: an unauthenticated request carries no token project, and a deployment with no control plane resolves no project scope — so the guard never interferes with those paths.

func ParseAllowedOrigins added in v0.6.0

func ParseAllowedOrigins(raw string, allowCredentials bool) ([]string, error)

ParseAllowedOrigins splits a comma-separated origin list and validates each entry. When allowCredentials is true the function refuses dangerous values: the wildcard "*", literal "null", empty entries, and malformed URLs. The returned slice preserves input order and case.

Why: this middleware unconditionally sets Access-Control-Allow-Credentials, so a wildcard origin in the allowlist would expose authenticated state to any origin. Failing fast at startup is the only safe behaviour.

func ParseTrustedProxies added in v0.6.0

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

ParseTrustedProxies parses a comma-separated list of CIDRs. Whitespace around entries is ignored. The empty string returns an empty slice, meaning "trust no proxies" — X-Forwarded-For is ignored entirely and only the TCP peer address is honoured.

func RateLimitMiddleware added in v0.6.0

func RateLimitMiddleware(limits []PathLimit, logger *zap.Logger) func(http.Handler) http.Handler

RateLimitMiddleware enforces per-IP+path quotas using the configured PathLimit entries. Requests whose path matches a PathLimit are checked against its limiter; everything else passes through.

The client IP comes from ClientIPHeader (set by ClientIPMiddleware), so this middleware must be installed after it. Rate-limited responses return 429 with a Retry-After header.

func RecoverMiddleware added in v0.6.0

func RecoverMiddleware(logger *zap.Logger) func(http.Handler) http.Handler

RecoverMiddleware catches panics in any downstream handler, logs them with the stack trace, and returns a generic 500 to the client.

Connect-Go does not recover panics by itself — a nil deref in any RPC handler would otherwise crash the goroutine and propagate to the HTTP server. At a million requests/day even a 0.001 % panic rate hits real users; we prefer a logged 500 to an unexplained TCP reset.

func SessionAuthMiddleware added in v0.8.0

func SessionAuthMiddleware(kp jwtpkg.KeyProvider, expectedTenant, expectedAudience string, requireAudience bool, cache *SessionCache) func(http.Handler) http.Handler

SessionAuthMiddleware verifies JWT Bearer tokens AND consults the session cache when the token carries a `sid` claim. This is the drop-in replacement for AuthMiddleware when mode=session is on.

When the cache is nil (mode=ttl) the behaviour is identical to AuthMiddleware. When the cache is non-nil:

  • Tokens without a sid claim are still accepted, so an upgrade deployment that ships verifiers ahead of issuers does not lock out in-flight tokens. The hot path stays the same for these.
  • Tokens with a sid claim are rejected when the cached/refreshed state is anything other than Active.

func ValidateAllowedOrigins added in v1.1.0

func ValidateAllowedOrigins(origins []string, allowCredentials bool) ([]string, error)

ValidateAllowedOrigins validates an already-split list of origins under the same rules as ParseAllowedOrigins. It exists for callers whose origins come from a structured source (a project's config_json array) rather than a comma-separated env var, so they need not round-trip through a join/split. Order and case are preserved; an all-empty input is ErrAllowedOriginsEmpty.

func WrapSessionRepository added in v0.8.0

func WrapSessionRepository(repo service.Repository, cache *SessionCache) service.Repository

WrapSessionRepository returns a Repository that invalidates the cache synchronously on session revocation. The wrapped Repository keeps all of its other behaviour intact.

Types

type FixedWindowLimiter added in v0.6.0

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

FixedWindowLimiter is a bounded, fixed-window in-memory rate limiter. Each key gets `limit` permits per `window`. Window boundaries are aligned to wall-clock seconds for simplicity; that's fine for human-scale abuse.

func NewFixedWindowLimiter added in v0.6.0

func NewFixedWindowLimiter(window time.Duration, limit, maxSize int) *FixedWindowLimiter

NewFixedWindowLimiter returns a limiter with the given per-key limit per window. limit <= 0 disables the limiter — Allow always returns true.

func (*FixedWindowLimiter) Allow added in v0.6.0

func (l *FixedWindowLimiter) Allow(key string, now time.Time) bool

Allow returns true if the key has remaining quota in the current window.

type PathLimit added in v0.6.0

type PathLimit struct {
	PathPrefix string
	Limiter    RateLimiter
	Tag        string // metric label / log field
}

PathLimit binds a path prefix to a RateLimiter. The middleware below gates each request by the first matching PathLimit entry.

type ProjectResolver added in v0.16.0

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

ProjectResolver resolves the per-request project ahead of tenant resolution and threads it through the request context as a service.ProjectScope. Resolution precedence:

  1. the X-Project-Key credential header (an explicit, invalid key is rejected — it is not silently downgraded to the default);
  2. the request Host, matched against a project auth-domain;
  3. the configured default project (zero-config single-project pin).

The resolver is the postgres control-plane store. Deployments whose driver has no control plane pass a nil resolver: every request then pins to the default project (steps 1–2 are skipped). When no default project is configured AND nothing resolves, the request passes through with no scope rather than being rejected, so non-project deployments are untouched.

type RPCMetrics added in v0.7.1

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

RPCMetrics owns the RED metric handles for the Connect handler chain. A single instance is registered against a prometheus.Registerer at boot; the HTTP middleware reads from it on every request.

func NewRPCMetrics added in v0.7.1

func NewRPCMetrics(reg prometheus.Registerer) (*RPCMetrics, error)

NewRPCMetrics constructs the metrics and registers them with reg. A non-nil error is returned if registration collides — typically a second call against the default registry within the same process. Pass nil to register against a fresh isolated registry (suitable for tests, integration harnesses, and benchmarks that want clean state); the production binary explicitly passes prometheus.DefaultRegisterer so /metrics serves the same counters that record traffic.

type RateLimiter added in v0.6.0

type RateLimiter interface {
	Allow(key string, now time.Time) bool
}

RateLimiter gates requests by a string key (typically a client IP plus a path bucket). The in-memory implementation is per-replica; a Redis-backed variant can replace it without changing call sites.

type ReadinessProbe added in v0.6.0

type ReadinessProbe interface {
	Ready(ctx context.Context) error
}

ReadinessProbe checks that the dependencies needed to serve traffic are reachable. Implementations should be cheap and bounded — `/readyz` is hit from load balancers on every health interval.

type RevokingSessionRepository added in v0.8.0

type RevokingSessionRepository struct {
	service.Repository
	// contains filtered or unexported fields
}

RevokingSessionRepository wraps a service.Repository so that every RevokeSession / RevokeSessionsForUser call also invalidates the in-process cache. Wiring sits in internal/app/app.go so the wrap only happens in `mode=session`.

func (*RevokingSessionRepository) RevokeSession added in v0.8.0

func (r *RevokingSessionRepository) RevokeSession(ctx context.Context, sid string, atMs int64) error

RevokeSession invalidates the cache before calling through. Order matters: invalidating after a failed RevokeSession would leak a stale "active" cache entry; invalidating before a successful one means the worst case is a single extra cache miss.

func (*RevokingSessionRepository) RevokeSessionsForUser added in v0.8.0

func (r *RevokingSessionRepository) RevokeSessionsForUser(ctx context.Context, userID string, atMs int64) error

RevokeSessionsForUser drops every cached entry. We don't index by user id (see InvalidateAll comment) and a deployer-side revoke happens at human latency, so the O(n) sweep is the right trade.

type SessionCache added in v0.8.0

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

SessionCache wraps a SessionLookup with an in-process TTL cache. The cache is keyed by SID; entries store the resolved active/revoked state plus the deadline past which the entry must be re-read.

The cache is invalidated synchronously on RevokeSession / RevokeSessionsForUser inside the same process. Cross-replica revocation is bounded by the TTL — a session revoked on replica A is invisible to replica B's cached entry for at most TTL seconds.

TTL = 0 means strict mode: every authenticated request reads the repository.

Implementation: a plain sync.Map of sid → cacheEntry. We deliberately avoid ristretto here — the cardinality is bounded by active sessions, eviction is by TTL (not size), and the workload is read-heavy with occasional invalidation. ristretto's strengths (admission policy, cost-based eviction) don't apply.

func NewSessionCache added in v0.8.0

func NewSessionCache(source SessionLookup, ttl time.Duration, metrics *SessionMetrics) *SessionCache

NewSessionCache constructs a cache. When ttl <= 0 the cache is in strict mode and every lookup goes through to source.

func (*SessionCache) Invalidate added in v0.8.0

func (c *SessionCache) Invalidate(sid string)

Invalidate drops cached state for sid. Called from RevokingSessionLookup wrappers so a same-process revoke is visible on the very next request.

func (*SessionCache) InvalidateAll added in v0.8.0

func (c *SessionCache) InvalidateAll()

InvalidateAll clears every cached entry. Used by RevokeSessionsForUser because we don't index entries by user id — the access pattern (revoke-on-replay) is rare enough that an O(n) Range is cheaper than maintaining a second index on the hot path.

func (*SessionCache) Lookup added in v0.8.0

func (c *SessionCache) Lookup(ctx context.Context, sid string) (SessionState, error)

Lookup resolves the session state for sid. TTL=0 always reads the repository; otherwise a cached entry whose deadline hasn't passed is served without I/O. Cache misses populate the entry under a per-entry lock so concurrent first-readers issue a single repo round-trip.

type SessionLookup added in v0.8.0

type SessionLookup interface {
	GetSessionBySid(ctx context.Context, sid string) (*service.SessionRecord, error)
}

SessionLookup is the interface the verification middleware uses to resolve a `sid` claim to an active session. It's narrower than service.Repository so tests can swap in a fake and the middleware only takes a dependency on what it actually needs.

type SessionMetrics added in v0.8.0

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

SessionMetrics owns the Prometheus handles exposed when the service is running in `mode=session`. The histogram measures end-to-end session lookup latency on the auth hot path — cache hits land in the sub-microsecond bucket; cache misses include the repository round-trip.

func NewSessionMetrics added in v0.8.0

func NewSessionMetrics(reg prometheus.Registerer) (*SessionMetrics, error)

NewSessionMetrics registers the metric handles with reg. Pass nil to register against a fresh isolated registry (suitable for tests). The production binary passes prometheus.DefaultRegisterer so /metrics serves the counters.

type SessionState added in v0.8.0

type SessionState int

SessionState is the result of a session lookup. The middleware rejects requests whose state is anything other than Active.

const (
	// SessionStateActive: row exists and revoked_at_ms == 0.
	SessionStateActive SessionState = iota
	// SessionStateRevoked: row exists with revoked_at_ms != 0.
	SessionStateRevoked
	// SessionStateMissing: no row for this sid. Always rejected — an
	// access token carrying a sid the service has never minted (or
	// has GC'd) must be treated as forged or expired-server-side.
	SessionStateMissing
)

Jump to

Keyboard shortcuts

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