Documentation
¶
Index ¶
- Constants
- Variables
- func AuthMiddleware(kp jwtpkg.KeyProvider, expectedTenant, expectedAudience string, ...) func(http.Handler) http.Handler
- func CORSMiddleware(globalOrigins []string) func(http.Handler) http.Handler
- func ClientIPFromContext(ctx context.Context) string
- func ClientIPMiddleware(trusted []*net.IPNet) func(http.Handler) http.Handler
- func HealthMiddleware(probe ReadinessProbe, next http.Handler) http.Handler
- func JWKSMiddleware(kp jwtpkg.KeyProvider) func(http.Handler) http.Handler
- func LoggingMiddleware(logger *zap.Logger) func(http.Handler) http.Handler
- func MetricsMiddleware(m *RPCMetrics) func(http.Handler) http.Handler
- func NewProjectResolver(defaultProjectID, defaultScopeID, defaultPrimaryAuthDomain string, ...) func(http.Handler) http.Handler
- func NewProjectScopeGuard() func(http.Handler) http.Handler
- func ParseAllowedOrigins(raw string, allowCredentials bool) ([]string, error)
- func ParseTrustedProxies(raw string) ([]*net.IPNet, error)
- func RateLimitMiddleware(limits []PathLimit, logger *zap.Logger) func(http.Handler) http.Handler
- func RecoverMiddleware(logger *zap.Logger) func(http.Handler) http.Handler
- func SessionAuthMiddleware(kp jwtpkg.KeyProvider, expectedTenant, expectedAudience string, ...) func(http.Handler) http.Handler
- func ValidateAllowedOrigins(origins []string, allowCredentials bool) ([]string, error)
- func WrapSessionRepository(repo service.Repository, cache *SessionCache) service.Repository
- type FixedWindowLimiter
- type PathLimit
- type ProjectResolver
- type RPCMetrics
- type RateLimiter
- type ReadinessProbe
- type RevokingSessionRepository
- type SessionCache
- type SessionLookup
- type SessionMetrics
- type SessionState
Constants ¶
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.
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.
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.
const AuthenticatedUserIDHeader = "X-Authenticated-User-Id"
AuthenticatedUserIDHeader carries the verified `sub` claim from the auth middleware to the Connect handler layer.
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.
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 ¶
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/BeginPasskeySignup": true, "/identity.v1.IdentityService/CompletePasskeySignup": 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.
var ErrAllowedOriginsEmpty = errors.New("cors: no allowed origins configured")
ErrAllowedOriginsEmpty is returned by ParseAllowedOrigins when the resolved list contains no origins.
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 ¶
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
ClientIPFromContext returns the resolved client IP stored by ClientIPMiddleware. Returns "" if the middleware did not run.
func ClientIPMiddleware ¶ added in v0.6.0
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 ¶
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 ¶
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
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
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
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
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
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
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.
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:
- the X-Project-Key credential header (an explicit, invalid key is rejected — it is not silently downgraded to the default);
- the request Host, matched against a project auth-domain;
- 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
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
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 )