Security & Authentication¶
See also:
api/restpackage on pkg.go.dev (security-scheme vocabulary is re-exported identically byapi/events/api/reqreply)Runnable demos:
examples/rest-api(bearer JWT + scopes, both chi and net/http servers) ·examples/events-api(demo_security_subscribemw.go: message-level security across mqtt v3/mqtt5/zeromq;demo_connect_level_security.go: connect-levelSecuredClient)
api/mcphas NO security methods by deliberate, permanent design — MCP security is handled by the host application (its own OAuth flow, or transport-level auth on the SSE/stdio transport itself), not by go-codex. This is the one place go-codex's security model is intentionally asymmetric with REST/events/reqreply.
go-codex documents security requirements in the spec and provides declarative hooks for runtime enforcement — the library does not import any crypto or JWT library. For REST, a security scheme is declared ONCE, via rest.SecurityMiddleware[In, Out](schemeName, rest.SecurityScheme, scopes) (building from an existing rest.SecurityScheme value), attached to the route via Route.Use(mw) — there is no builder-level scheme registry, and rest.WithSecurityScheme was REMOVED (there is no metadata-only registration anymore; every declared scheme is a real requirement) — and the SAME declaration is consumed identically by both the server (Route.Register/RegisterHandle) and the client (Route.ClientHandle), so one route definition gets IDENTICAL credential-format enforcement on both ends. Runtime credential validation itself is attached per-route via ONE of two classes (docs/design/d-0003-codec-declared-middlewares.md's Addendum 7): the REUSABLE class (Middleware.WithReceive/WithSend, attached via .Use()) for a Req-free check, or the BOUND class (BoundMiddleware/BoundClientMiddleware, attached via Route.HandleBoundMW/Route.ClientBoundMW) when the Fn needs the route's own decoded Req — see "Codec-backed Security" below.
Connection-level vs message-level security¶
REST is stateless per-request — every rest.CallWithTransport/incoming request IS a
fresh connection-equivalent, so there's no "connection level" distinct
from "message level" at all.
MQTT (both 3.1.1 and 5.0) is different: connections are long-lived and
stateful. The caller calls Connect() ONCE, entirely outside go-codex
(MQTTClient/pahomqtt.Client deliberately expose no connection-lifecycle
methods — go-codex never manages Connect()/Disconnect()), then reuses
that SAME client across many Subscribe/Publish/Serve/Call
invocations for the program's lifetime. This means TWO independent
security layers exist:
- Connection-level — broker ACLs (Mosquitto
acl_file, EMQX rules, etc.) tied to CONNECT-time username/password/TLS client cert, configured ENTIRELY in the caller's own connect code, before anyevents.Channel/reqreply.Routeis touched. This already works today with ZERO go-codex involvement, and is sufficient for "one connection = one identity" topologies (e.g. one IoT device, one dedicated broker connection, one CONNECT-time credential).
go-codex adds an OPTIONAL, codec-validated FORMAT check on top via
mqtt5.SecuredClient/mqtt.SecuredClient (protocol-symmetric across
MQTT 3.1.1 + MQTT5, unlike the message-level model below, since CONNECT
username/password exists in both protocol versions). Validation happens
ONCE, synchronously, at construction — never per message:
var connectBearerAuth = mqtt5.ConnectSecurityScheme{SecurityScheme: events.BasicScheme().SecurityScheme}.
WithCodec(codex.String().Refine(validate.MinLen(8)))
client := paho.NewClient(...)
if _, err := client.Connect(ctx, &paho.Connect{
Username: "svc-account", Password: []byte(token),
}); err != nil { /* handle */ }
secured, err := mqtt5.NewSecuredClient(client, connectBearerAuth, "svc-account", token)
if err != nil { /* malformed credential — client is never used */ }
// secured is a drop-in replacement — every NewSubscribeTransport/
// NewPublishTransport/Serve/Call call site below is UNCHANGED from how
// it would look with the raw client.
transport := mqtt5.NewSubscribeTransport[T](secured, router, opts)
err = events.SubscribeHandle(ctx, sub, transport, fn)
NewSecuredClient combines username+password into a single
"username:password" string before validating (the MQTT CONNECT packet
carries them as two separate wire fields — this combined form is a
VALIDATION-TIME representation only, never transmitted; mirrors
examples/go-edge-models/app/registry's internal.BasicAuthCodec
convention for HTTP Basic auth). On failure, returns
ConnectSecurityCredentialError and the underlying client is never
touched. *SecuredClient satisfies MQTTClient/pahomqtt.Client
transparently via Go struct embedding — no other code changes needed.
2. Message-level (WithSecurityScheme + a security-shaped
SubscribeMW/PublishMW-paired implementation, shipped for MQTT v3,
MQTT5, AND ZeroMQ — see below) — opt-in, per-message codec-validated
credentials, needed when ONE connection carries traffic for MULTIPLE
logical identities (e.g. a gateway relaying many devices' messages
over one shared broker connection) and broker ACLs alone can't
distinguish between them.
Both layers are independent and composable — use connection-level ACLs for
coarse-grained, per-connection authorization, and a message-level
SubscribeMW/PublishMW-paired implementation for fine-grained,
per-message claims within a single shared connection. Neither requires
the other.
TLS / transport encryption is always the caller's own concern¶
TLS (transport encryption) is a DIFFERENT axis from everything above —
it protects the wire, not the message/identity — and go-codex
deliberately has NO opinion on it anywhere: it is never part of an
OpenAPI/AsyncAPI spec (neither format has a "requires TLS" field; that's
implied by the servers entry's own scheme, e.g. https://), and never
touches go-codex's security-scheme model. Confirmed per transport:
- REST: client-side, the caller supplies its own
*http.Clienttonethttp.Call/rest.CallWithTransport— TLS is entirelyhttp.Transport.TLSClientConfig's concern (client certs, customRootCAs, etc.), go-codex never constructs anhttp.Clientitself. Server-side,b.Attach(nethttp.NewServerTransport(...))/b.Attach(chiadapter.NewServerTransport(...))just wire amux/router; the caller builds their own*http.Server(with or withoutTLSConfig) around it — go-codex's ownServer.Serveexplicitly documents this ("a caller needing full control over TLS/timeouts/etc. builds their own*http.Server"). - mqtt5/mqtt: the optional
Connect()convenience wrapper (ConnectOptions.TLS *tls.Config) is a PURE pass-through to Go's owntls.Dialer/pahomqtt.ClientOptions.SetTLSConfig— not part of any spec/security-scheme mechanism, purely a connection-dial convenience. Callers needing more control bypass it entirely and construct their own already-connected client, same asSecuredClientabove. - zeromq: no TLS/CURVE surface exists at all —
adapters/zeromqhas ZERO dependency on any concrete ZeroMQ library (not even ingo.mod); it is a pureFramedSocketinterface the caller implements from whatever binding they choose (e.g.github.com/pebbe/zmq4). CURVE (libzmq's own transport-encryption/auth mechanism) would be configured entirely on the caller's own concrete socket, before wrapping it as aFramedSocket— a deliberate, permanent, researched decision (CURVE/ZAP is the caller's own concern; see D-0004's Addendum for the full rationale), not something go-codex exposes today.
Security schemes (REST)¶
import (
"github.com/DaniDeer/go-codex/api/rest"
"github.com/DaniDeer/go-codex/validate"
)
// Declare each scheme ONCE as a shared value — Go's ordinary "declare once,
// reference everywhere" idiom, no builder-level registry needed.
var bearerAuthScheme = rest.SecurityScheme{
SecurityScheme: rest.BearerScheme("JWT"),
}.WithCodec(codex.String().Refine(validate.BearerToken)) // format check before HandleMW/before send
var bearerAuth = rest.SecurityMiddleware[struct{}, struct{}]("bearerAuth", bearerAuthScheme, []string{"write:users"})
var apiKeyAuthScheme = rest.SecurityScheme{
SecurityScheme: rest.APIKeyScheme("X-API-Key", "header"),
}
var apiKeyAuth = rest.SecurityMiddleware[struct{}, struct{}]("apiKey", apiKeyAuthScheme, nil)
b := rest.NewServer(rest.Info{Title: "User API", Version: "1.0.0"})
// Attach the scheme directly to every route that needs it, via Use.
createUser := rest.NewRoute[CreateUserReq, User]("POST", "/users",
reqCodec, respCodec,
rest.RouteMeta{OperationID: "createUser"},
).Use(bearerAuth)
err := createUser.Register(b) // error only — see "Runtime enforcement" below for HandleMW
Server.OpenAPISpec() aggregates components.securitySchemes automatically from every registered route's own .Use()-attached declarations — no separate builder-level step needed. When two routes declare the same scheme name with different values, the last-registered route wins (no error); define the scheme once as a shared value (as above) to avoid relying on this.
Built-in scheme constructors:
rest.BearerScheme("JWT") // Authorization: Bearer <token>
rest.BasicScheme() // Authorization: Basic <base64>
rest.APIKeyScheme("X-API-Key", "header") // header-based API key
rest.APIKeyScheme("api_key", "query") // query param API key
rest.OAuth2Scheme(rest.OAuthFlows{...}) // OAuth 2.0
rest.OpenIDConnectScheme("https://.../.well-known")// OIDC discovery
Global and per-route security¶
Server.AddGlobalSecurity/RouteMeta.Security answer "which routes require auth" — a SEPARATE, unchanged concern from a .Use()-attached scheme declaration ("what does a named scheme look like"). RouteMeta.Security's nil (inherit global) and []rest.SecurityRequirement{} (explicit opt-out) states are UNCHANGED by the security-declaration redesign — only the THIRD state (a non-empty, MANUALLY-set Security paired with metadata-only registration) was removed, since rest.SecurityMiddleware now populates RouteMeta.Security automatically as part of .Use():
// Global security — applies to all operations by default.
b.AddGlobalSecurity(rest.Require("bearerAuth"))
// Per-route: bearerAuth is the SAME shared Middleware value from the
// section above — .Use() populates RouteMeta.Security automatically.
createUser := rest.NewRoute[CreateUserReq, User]("POST", "/users",
reqCodec, respCodec,
rest.RouteMeta{OperationID: "createUser"},
).Use(bearerAuth)
err := createUser.Register(b)
// Explicitly public — empty slice overrides global security (unaffected
// by the security-declaration redesign; no .Use() call needed at all).
publicRoute := rest.NewRoute[struct{}, Info]("GET", "/health",
codex.Empty, infoCodec,
rest.RouteMeta{
Security: []rest.SecurityRequirement{}, // no auth required
},
)
err = publicRoute.Register(b)
Security requirement shapes — single, OR, AND, opt-out, inherit¶
rest.SecurityRequirement is map[string][]string (scheme name → required
OAuth2 scopes) — a single map can hold MULTIPLE scheme names as keys, and
RouteMeta.Security is a SLICE of these maps. This gives two independent
axes of combination:
- Within one map: every key (scheme name) must be satisfied together — AND.
- Across the slice: any one map fully satisfied is enough — OR.
rest.Require(name, scopes...) builds a SINGLE-KEY map. Each
rest.SecurityMiddleware value, attached via
.Use(), contributes exactly ONE scheme-name key, merged AND-wise into
RouteMeta.Security's FIRST map entry — calling .Use() twice with two
DIFFERENT scheme declarations naturally produces the AND case (shape
2 below) with zero manual RouteMeta.Security needed at all.
1. Single scheme required (the baseline shown above):
route := rest.NewRoute[CreateUserReq, User]("POST", "/users",
reqCodec, respCodec, rest.RouteMeta{OperationID: "createUser"},
).Use(bearerAuth) // bearerAuth = rest.SecurityMiddleware[struct{}, struct{}]("bearerAuth", ..., []string{"write:users"})
2. AND — both required together (two .Use() calls, each declaring a
DIFFERENT scheme, merge into ONE map — no manual RouteMeta.Security
needed):
route := rest.NewRoute[CreateUserReq, User]("POST", "/users",
reqCodec, respCodec, rest.RouteMeta{OperationID: "createUser"},
).Use(bearerAuth).Use(apiKeyAuth)
// Descriptor.Security == [{"bearerAuth": [...], "apiKey": []}] — AND
3. Explicit opt-out (empty slice, NOT nil — overrides global security
for just this route; no .Use() call needed):
4. Inherit global (omit Security entirely — the default):
5. Same scheme, different scopes per route — each route builds its
OWN rest.Middleware[In, Out] value from the SAME shared rest.SecurityScheme
(the scheme's SPEC metadata is reusable; the SCOPES are per-declaration):
// route A — needs the "profile" scope:
routeA := rest.NewRoute[...](...).Use(rest.SecurityMiddleware[struct{}, struct{}]("bearerAuth", bearerAuthScheme, []string{"profile"}))
// route B — needs the "admin" scope:
routeB := rest.NewRoute[...](...).Use(rest.SecurityMiddleware[struct{}, struct{}]("bearerAuth", bearerAuthScheme, []string{"admin"}))
Known limitation — OR across TWO scheme-declaring
.Use()calls is NOT directly expressible.applySecurityDeclarationsALWAYS merges every.Use()-attached scheme intoRouteMeta.Security's FIRST map entry (AND-only) — it has no mechanism to route a scheme declaration into a SEPARATE, alternative map entry. A manually-setRouteMeta.Securitywith multiple OR'd map entries is NOT corrupted by.Use()for schemes it does NOT also declare, but attaching.Use()for a scheme ALSO present in that manual structure still merges an extra key into entry[0], producing an unintended shape. If your API genuinely needs "scheme A alone OR scheme B alone" (not "A syntax AND B"), build the requirement/spec manually viaRouteMeta.Securityand register each scheme's OpenAPI metadata without a scope-bearing.Use()call at all — this is a real, open gap in the current declaration surface, not a documented feature; track it before relying on it in production.
Runtime enforcement (nethttp / chi adapters)¶
route := createUser.WithHandler(handler).WithOptions(nethttp.Options{
// SecurityFunc is called after Codec format validation passes.
// Receives the *http.Request and the route's declared security requirements.
SecurityFunc: func(ctx context.Context, r *http.Request, reqs []rest.SecurityRequirement) error {
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
return jwtlib.VerifyScopes(token, reqs)
},
})
rest.Register(b)
b.Attach(nethttp.NewServerTransport(nethttp.ServerTransportOptions{Mux: mux, Addr: addr}))
go func() { _ = b.Serve(ctx) }()
The adapter enforcement sequence:
1. Extracts the raw credential per scheme type (Authorization: Bearer <token>, X-API-Key: <key>, etc.)
2. Validates it via SecurityScheme.Codec → returns rest.SecurityCredentialError + 401 on failure
3. Calls SecurityFunc → returns rest.SecurityError + 401 on rejection
Routes with nil Security (default) trigger enforcement when global security is set.
rest.CallWithTransport (client-side) runs the SAME sequence, symmetrically, on the OUTGOING request before it is sent — see "HTTP client — credential-providing ClientMW" below. rest.Client.Call (bound via Client.Attach(nethttp.NewClientTransport(...))) runs the SAME enforcement too — it is full-featured: security/credential ClientMW, path/query/header/cookie params, per-call format override, and error-pattern decoding are all supported (see adapters/nethttp/clienttransport.go's own doc comment for confirmation there is no remaining "v1 scope" limitation).
Codec-backed Security — BoundMiddleware/BoundClientMiddleware + GrantedScopes¶
Runnable demo:
examples/rest-api/demo_granted_scopes_context_field.go— a REALAuthIn/AuthOutpair,HandleBoundMW/ClientBoundMW, correct-vs-wrong-scope enforcement, andSetContextFieldFromInpropagating the authenticated token to the handler with zero manual re-decoding.
A codec-backed rest.Middleware[In, Out] (built via rest.SecurityMiddleware[In, Out])
can carry a Security declaration and be attached in ONE of two ways,
depending on whether the Fn needs access to the route's own decoded
Req (see docs/design/d-0003-codec-declared-middlewares.md's Addendum 7 for the full design
this section documents):
Reusable class — Middleware[In, Out], attached via .Use(). The
Fn is embedded via WithReceive/WithSend and is Req-free — the
default choice whenever the credential check only needs the decoded
header/cookie/query value, never the request body/path itself:
type APIKeyIn struct{ Key string }
type APIKeyOut struct{ GrantedScopes map[string][]string }
apiKeyMw := rest.SecurityMiddleware[APIKeyIn, APIKeyOut]("apiKeyAuth",
rest.SecurityScheme{SecurityScheme: rest.APIKeyScheme("X-Api-Key", "header")}, nil,
).WithRequestHeader(rest.NewRequiredHeaderParam("X-Api-Key", codex.String(),
func(in APIKeyIn) string { return in.Key },
func(in *APIKeyIn, v string) { in.Key = v },
)).WithReceive(func(ctx context.Context, in APIKeyIn) (APIKeyOut, error) {
if !validKey(in.Key) {
return APIKeyOut{}, errors.New("invalid API key")
}
return APIKeyOut{GrantedScopes: map[string][]string{"apiKeyAuth": nil}}, nil
})
route := rest.NewRoute[CreateUserReq, User]("POST", "/users", reqCodec, respCodec,
rest.RouteMeta{OperationID: "createUser"},
).Use(apiKeyMw)
Bound class — BoundMiddleware[Req, In, Out], attached via
HandleBoundMW/ClientBoundMW. Fn additionally receives *Req (server)
or Req by value (client) — use this when the declare-time spec (route
path/codec) and the runtime implementation genuinely need to live in
SEPARATE files/packages (the common case when routes/ declares a scheme
reused across SEVERAL different Req types, e.g.
examples/rest-api/routes/middleware.go's BoundScopeServerMW/
BoundScopeClientMW helpers), or when the credential itself must be
read from/written to the decoded Req struct directly (no header/cookie/
property side-channel available at all — e.g. an in-payload credential
field):
route := rest.NewRoute[CreateUserReq, User]("POST", "/users", reqCodec, respCodec,
rest.RouteMeta{OperationID: "createUser"},
).HandleBoundMW(rest.BoundSecurityMiddleware[CreateUserReq, APIKeyIn, APIKeyOut](
"apiKeyAuth", rest.SecurityScheme{SecurityScheme: rest.APIKeyScheme("X-Api-Key", "header")}, nil,
func(ctx context.Context, req *CreateUserReq, in APIKeyIn) (APIKeyOut, error) {
if !validKey(in.Key) {
return APIKeyOut{}, errors.New("invalid API key")
}
return APIKeyOut{GrantedScopes: map[string][]string{"apiKeyAuth": nil}}, nil
},
).WithRequestHeader(rest.NewRequiredHeaderParam("X-Api-Key", codex.String(),
func(in APIKeyIn) string { return in.Key },
func(in *APIKeyIn, v string) { in.Key = v },
)))
HandleBoundMW populates rb.meta.Security/rb.securitySchemes
automatically — no separate .Use() call is needed (and attempting one
for the SAME scheme name on the SAME route is a DuplicateMiddlewareNameError,
same as attaching two Security declarations any other way). The prior
"Known gap" documented in earlier revisions of this page (.Use(mw) +
a bound HandleMW/ClientMW pairing throwing DuplicateMiddlewareNameError)
is now structurally impossible — BoundMiddleware is a DIFFERENT Go type
from Middleware, with its own dedicated attach methods, so the two
styles can never collide on one value.
A server-side BoundMiddleware and a client-side BoundClientMiddleware
for the SAME scheme name CANNOT be attached to one shared route value
either, for the identical reason — unlike Middleware[In,Out] (which
can carry both a WithReceive and a WithSend Fn and be .Use()'d ONCE
for both roles), HandleBoundMW/ClientBoundMW each independently
contribute a spec entry under the scheme's declaration name, so combining
them on one route also trips DuplicateMiddlewareNameError. Build TWO
SEPARATE route values instead — one per role, exactly like every example
in this repo does (see examples/adapters-sse's secureServerMw/
securedClientMw split, or examples/rest-api/client/client.go's
pattern of deriving a fresh client-side route from the shared spec-only
base).
The legacy raw-adapter credential-pairing mode
(HandleMW(&mw, rawFn)/ClientMW(&mw, rawFn) with a reflection-detected
bound-shaped fn) is PERMANENTLY CLOSED — HandleMW/ClientMW now
reject any codec-backed Middleware[In, Out] value outright
(MiddlewareMisattachedError), whether or not fn's shape looks bound.
Every Security need expressible under the old mechanism re-expresses
cleanly under the two classes above; see
docs/design/d-0003-codec-declared-middlewares.md's Addendum 7's investigation for the
case-by-case migration mapping.
fn gets *Req/Req access in the bound class (read/enrich, exactly
like the generic middleware mechanism — see
Feature: Codec-Declared Middleware), and
In's own header/cookie/query merge fields decode declaratively from the
request in BOTH classes — no manual r.Header.Get(...) anywhere.
Out carries the RESOLVED "conventional field" convention: a field
named GrantedScopes map[string][]string, read by the adapter via
reflection and fed into the SAME rest.CheckScopes call the legacy
SecurityFunc/credential path already used — a route requiring specific
scopes (not just scheme presence) checks them identically regardless of
which class supplied the grant. This field must be populated even when
zero specific scopes are required — an Out{} zero value (nil map)
means NOTHING satisfies the scheme at all, causing an otherwise-successful
Fn to still be treated as a rejection; return
Out{GrantedScopes: map[string][]string{"<schemeName>": nil}} for a
scheme with no scope requirements.
On the CLIENT side, WithSend (reusable) / ClientBoundMW (bound) are
the credential-SUPPLYING counterparts — a Fn shaped
func(ctx context.Context) (In, error) (reusable) or
func(ctx context.Context, req Req) (In, error) (bound, Req BY VALUE)
returns the declarative In value to merge into the request, instead of
hand-building an http.Header value. rest.NewOmitEmptyHeaderParam
is the key building block for an anonymous-access credential Fn — a
field declared with it OMITS its header entirely when the current value
is the zero value (e.g. an empty token), rather than sending an empty
header value; see examples/go-edge-models/app/registry/auth.go's
newAuthCredentialFunc and examples/go-edge-models/models/docker/registry/security.go's
BearerAuthDeclaration for a complete, runnable, real-world example of
this exact pattern (a registry client that may or may not need a
credential, decided per-call by whether an actual token was obtained).
Coverage note — ports-facing binding adapters (IngestAdapter,
LatestAdapter, SSEAdapter, and stream.go's HandlerLatest/
PipelineHandler, both nethttp and chi) dispatch
HandleBoundMW/.Use()-attached middleware/Security identically to
Server.Attach. A route's MiddlewareHandlers run through the exact
same unified sequence regardless of whether the route is wired via
b.Attach(...) or bound directly to a ports.SourceAdapter/SinkAdapter/
LatestAdapter — "declare once, works everywhere" holds across both
consumption paths.
Credential format validation¶
Use validate constraints to validate raw credential strings before a
Security Fn runs:
// Bearer token: non-empty, no leading/trailing whitespace
codex.String().Refine(validate.BearerToken)
// UUID v4 API key
codex.String().Refine(validate.UUID)
// Non-empty API key
codex.String().Refine(validate.NonEmptyString)
HTTP client — credential-providing WithSend/ClientBoundMW¶
For rest.CallWithTransport and rest.Client.Call alike (both are
full-featured — see the "Runtime enforcement" section above), provide
credentials via Middleware.WithSend (reusable class) or
BoundClientMiddleware attached via Route.ClientBoundMW (bound class —
see "Codec-backed Security" above for when to choose which). There is no
per-call override anymore — a route's credential-supplying middleware
applies to EVERY call made through that route value; a caller needing a
genuinely different credential for one call builds a DIFFERENT Route
value via a fresh .Use(mw.WithSend(...))/.ClientBoundMW(...) call:
type AuthIn struct{ Token string }
type AuthOut struct{ GrantedScopes map[string][]string }
bearerMw := rest.SecurityMiddleware[AuthIn, AuthOut]("bearerAuth",
rest.SecurityScheme{SecurityScheme: rest.BearerScheme("JWT")}, nil,
).WithRequestHeader(rest.NewRequiredHeaderParam("Authorization", codex.String(),
func(in AuthIn) string { return in.Token },
func(in *AuthIn, v string) { in.Token = v },
))
securedRoute := rest.NewRoute[GetDataReq, Data]("GET", "/data",
reqCodec, respCodec, rest.RouteMeta{OperationID: "getSecuredData"},
).Use(bearerMw.WithSend(func(ctx context.Context) (AuthIn, error) {
return AuthIn{Token: getToken(ctx)}, nil
}))
handle := securedRoute.ClientHandle()
transport := nethttp.NewClientTransport(nethttp.ClientTransportOptions{HTTPClient: http.DefaultClient, BaseURL: serverURL})
data, err := rest.CallWithTransport(ctx, transport, handle, GetDataReq{}, rest.ClientCallOptions{})
The credential-providing Fn is GATED by the Security declaration's own
scheme name vs. the route's declared requirements — it only runs when the
route actually declares that scheme. For static credentials, use
ClientCallOptions.ExtraHeaders instead.
Symmetric credential-format validation. If bearerMw's declared
scheme carries a non-nil Codec — populated identically on BOTH
Route.Register/RegisterHandle and Route.ClientHandle — Call
validates the credential-providing Fn's returned value against that
SAME Codec before sending, reusing the identical extraction/validation
logic the server Handler uses on an incoming request. A malformed
credential returns rest.SecurityCredentialError locally, before any
network call, and fires stats.SecurityObserver.RecordSecurityRejection
— catching a credential bug immediately instead of after a round trip and
a generic 401.
This does NOT require a credential-providing Fn to be attached at all on
a secured route, and the check only fires when one actually returns
something: no WithSend/ClientBoundMW attached, or one that
deliberately returns a zero In to mean "this call needs no credential"
(e.g. an auth flow that first probes whether the specific server instance
requires auth at all — see examples/go-edge-models/app/registry's
newAuthCredentialFunc), remains a deliberate non-error. The request is
simply sent without the credential, and it's up to the server to accept or
reject it — symmetric with server-side enforcement.
Caching a credential-providing Fn¶
Re-authenticating on every call is wasteful when the underlying inner
credential fetch is itself an HTTP round trip (e.g. an OAuth2 token
endpoint, or examples/go-edge-models/app/registry's registry token
exchange). nethttp.NewCachingCredentialFunc wraps the OLD, now-closed
raw CredentialFunc shape
(func(ctx context.Context, reqs []rest.SecurityRequirement) (http.Header, error))
with TTL-based caching — it does NOT apply to the current
WithSend/ClientBoundMW Fn shape
(func(ctx context.Context) (In, error)/func(ctx context.Context, req Req) (In, error)).
Write a small, equivalent TTL-cache wrapper for the current shape
instead — examples/adapters-nethttp-client/main.go's cachingAuthIn
is a complete, runnable reference (TTL-based, single-credential cache,
invalidate func() returned for wiring into OnCredentialRejected):
func cachingAuthIn(inner func(context.Context) (AuthIn, error), ttl time.Duration) (fn func(context.Context) (AuthIn, error), invalidate func()) {
var mu sync.Mutex
var cached AuthIn
var expiresAt time.Time
fn = func(ctx context.Context) (AuthIn, error) {
mu.Lock()
defer mu.Unlock()
if time.Now().Before(expiresAt) {
return cached, nil
}
v, err := inner(ctx)
if err != nil {
return AuthIn{}, err
}
cached, expiresAt = v, time.Now().Add(ttl)
return cached, nil
}
invalidate = func() {
mu.Lock()
defer mu.Unlock()
expiresAt = time.Time{}
}
return fn, invalidate
}
inneris invoked at most once per TTL window.- The returned
invalidate func()immediately expires the cached credential. A cache wrapper does NOT know when a credential is rejected — the server only reveals that via a 401 response, which is observed byCall, not by the credential Fn (which runs before the network call). WireinvalidatetoClientCallOptions.OnCredentialRejectedand retry once, explicitly:
cachedFn, invalidate := cachingAuthIn(inner, time.Hour)
callOpts := rest.ClientCallOptions{
OnCredentialRejected: invalidate, // purges the cache; does NOT retry
}
securedRoute := rest.NewRoute[GetDataReq, Data]("GET", "/data",
reqCodec, respCodec, rest.RouteMeta{OperationID: "getSecuredData"},
).Use(bearerMw.WithSend(cachedFn))
handle := securedRoute.ClientHandle()
resp, err := rest.CallWithTransport(ctx, transport, handle, req, callOpts)
var statusErr nethttp.UnexpectedStatusError
if errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusUnauthorized {
resp, err = rest.CallWithTransport(ctx, transport, handle, req, callOpts) // fresh credential now
}
OnCredentialRejected is purely a notification hook — CallWithTransport
never retries automatically, keeping control flow explicit and in the
caller's hands. OnCredentialRejected fires correctly whether the
credential came from the reusable class's WithSend or the bound
class's ClientBoundMW — both are recognized by
clientMiddlewareSatisfiesAny (adapters/nethttp/client_middleware.go),
not just the legacy ClientImplementations path.
One cache wrapper instance is one cache entry — construct a separate instance per credential scope (e.g. per host/registry) when different routes need independently-cached credentials.
Live-reloadable credentials — codex.Mutable[T]/codex.Cacheable[T]¶
SecurityFunc (server) and a credential-providing ClientMW-attached Fn
(client) are plain closures — nothing above requires them to close over a
static value. A rotating signing key (server-side SecurityFunc) or a
TTL-bearing credential cache (client-side Fn) can capture a
*codex.Mutable[T]/*codex.Cacheable[T] and call .Get() INSIDE the
closure body, exactly like any other variable — no dedicated wrapper API
exists or is planned for this; a plain static value,
NewCachingCredentialFunc's own hand-rolled cache, and these two
containers are all equally valid choices for the SAME closure field. See
docs/concepts/codec.md's "Composing with adapters" subsection (under
Getter/Setter) for the full pattern, including the one real gotcha
(.Get() must never be hoisted out of the closure), and
examples/mutable-security-keys for a runnable end-to-end demo wiring
both containers into real nethttp server/client hooks.
Security for event channels (AsyncAPI)¶
Just like REST, an events security scheme is declared ONCE and attached to
a channel's subscribe/publish role via .Use() — mirroring
rest.SecurityMiddleware/Route.Use exactly:
import (
"github.com/DaniDeer/go-codex/api/events"
"github.com/DaniDeer/go-codex/validate"
)
// Declare each scheme ONCE as a shared value.
var bearerAuthScheme = events.SecurityScheme{
SecurityScheme: events.BearerScheme("JWT"),
}.WithCodec(codex.String().Refine(validate.BearerToken))
var bearerAuth = events.SecurityMiddleware[struct{}, struct{}]("bearerAuth", bearerAuthScheme, nil)
eventsClient := events.NewClient(events.WithInfo(events.Info{Title: "User Events", Version: "1.0.0"}))
eventsClient.AddServer("production", events.Server{
URL: "broker.example.com",
Protocol: "mqtt5",
})
// Attach the scheme directly to the subscribe role that needs it, via Use —
// CheckCoverage (run unconditionally by Subscriber.Handle) rejects a
// declared Security requirement with no attached SubscribeMW implementation
// satisfying it.
userCreatedSub := events.NewChannel[UserCreated]("user/created", codec,
events.ChannelMeta{Description: "A user was created"},
).WithSubscribe(events.Subscribe{
Summary: "Receive user created events",
Security: []events.SecurityRequirement{events.Require("bearerAuth")},
}).Use(bearerAuth)
Client.AsyncAPISpec() aggregates components.securitySchemes automatically
from every registered channel's own .Use()-attached declarations — no
separate builder-level step needed, same last-registered-wins-on-collision
policy as REST.
events.WithSecurityScheme is a DEPRECATED, older declaration mechanism
(kept only for backward-compat regression coverage) — new code should use
SecurityMiddleware + .Use() as shown above.
MQTT5 adapter — the server side runs a BUILT-IN codec-based credential
check (extracting the "Authorization" MQTT5 User Property for http/oauth2/
openIdConnect schemes, or the User Property named scheme.Name for
apiKey schemes) BEFORE any additionally-attached security Fn — same
two-step order as the nethttp/chi request pipeline.
MQTT 3.1.1 (adapters/mqtt) has no per-message metadata channel, so
User-Property-style codec extraction only applies to MQTT5 — but message-level
security is NOT absent for MQTT 3.1.1. The OLD imperative
SubscribeOptions.SecurityFunc/PublishOptions.CredentialFunc escape
hatch was removed entirely (BREAKING — see
D-0002's own
"Addendum: Observability Core Consolidation, SecurityFunc Retirement,
and examples/events-api"); message-level security now lives ONLY in the
declarative Middleware/BoundSubscribeMiddleware/BoundPublishMiddleware
mechanism documented below.
Use connection-level mqtt.SecuredClient for connection-level (not
message-level) enforcement.
ZeroMQ pub/sub (adapters/zeromq) has a message-level security
mechanism too — the OLD SubscribeOptions.SecurityFunc/
PublishOptions.CredentialFunc fields were likewise removed entirely
(same Phase 2 as above); ZeroMQ's [topic, payload] frames carry nothing
beyond what's already decoded into T, so a production channel typically
needs an in-payload credential field (e.g. a Token string field) for a
bound middleware to read/write, since there's no header/property
side-channel to extract one from instead — see
examples/reqreply-api/routes/routes.go's
OAuthComputeReq for the in-payload-field pattern applied for real
(reqreply, not pub/sub, but the SAME shape). There is still no
connection-level SecuredClient equivalent for ZeroMQ (its base
REQ/REP/PUB/SUB patterns have no CONNECT-time credential handshake to
validate) — a permanent, deliberate, researched exclusion (CURVE/ZAP is
the caller's own concern), plus a CLOSED optional out-of-band
frame-based mechanism question (no driver ever surfaced); see
D-0004's Addendum
for the full rationale (the original tracking doc,
zeromq-security.md, has since shipped and been deleted per its own
graduation policy).
Codec-backed Security — BoundSubscribeMiddleware/BoundPublishMiddleware + GrantedScopes¶
Runnable demo:
examples/events-api/demo_granted_scopes_context_field.go— a REALAuthIn/AuthOutpair,SubscribeBoundMW, correct-vs-wrong-scope enforcement (mqtt5), andSetContextFieldFromInpropagating the authenticated API key to the subscribe handler with zero manual re-decoding.
Mirrors api/rest's identical mechanism (see "Codec-backed Security"
under "Runtime enforcement" above) — see
docs/design/d-0003-codec-declared-middlewares.md's Addendum 7 for the full design this section
documents. A codec-backed events.Middleware[In, Out] (built via
events.SecurityMiddleware[In, Out]) can be attached in ONE of two ways,
depending on whether the Fn needs access to the channel's own decoded
message struct:
Reusable class — Middleware[In, Out], attached via .Use(). The
Fn is embedded via WithReceive/WithSend and is T-free — usable
whenever the credential check only needs a decoded topic/property value
(or nothing), never the message struct itself.
IMPORTANT — this class can ONLY ever be used when the channel's
Subscribe.Security is left UNDECLARED entirely. events' Subscribe-side
WithReceive Fn structurally has NO Out return at all (func(ctx, In)
error) — a confirmed, genuine asymmetry with REST/reqreply's own
WithReceive (which DOES return (Out, error)) — so it can NEVER
populate GrantedScopes. Every adapter's unified events.CheckScopes
call requires the scheme name to be PRESENT as a map key in the merged
grants, which only a GrantedScopes-producing (bound-class) handler can
ever supply — so a reusable-class attachment can NEVER satisfy a
DECLARED Subscribe.Security requirement, EVEN ONE REQUIRING ZERO
SPECIFIC SCOPES. Use it only as an unpaired, non-enforced presence/
validity gate (fn's own returned error still rejects the message, it's
just never tied to a formal scope grant):
type APIKeyIn struct{ Key string }
apiKeyMw := events.SecurityMiddleware[APIKeyIn, struct{}]("apiKeyAuth",
apiKeyAuthScheme, nil,
).WithSubscribeProperty(events.NewPropertyParam("X-API-Key", codex.String(),
func(in APIKeyIn) string { return in.Key },
func(in *APIKeyIn, v string) { in.Key = v },
)).WithReceive(func(ctx context.Context, in APIKeyIn) error {
if !validAPIKeys[in.Key] {
return fmt.Errorf("unknown API key %q", in.Key)
}
return nil
})
// sensorDataSub must NOT declare Subscribe.Security for this to work —
// see the Bound class below for the declared-Security case.
sensorDataSub = sensorDataSub.Use(apiKeyMw)
Bound class — BoundSubscribeMiddleware[T, In, Out]/BoundPublishMiddleware[T, In, Out],
attached via SubscribeBoundMW/PublishBoundMW. Fn additionally
receives *T (subscribe) or T by value (publish) — use this when the
credential (or any GrantedScopes grant) can ONLY be derived by reading/
writing the channel's own decoded message struct directly (no property/
topic side-channel available — e.g. an in-payload credential field), or
when returning GrantedScopes from the SUBSCRIBE side specifically (the
reusable class's structural limitation above):
type BearerIn struct{ Token string }
type BearerOut struct{ GrantedScopes map[string][]string }
bm := events.BoundSecuritySubscribeMiddleware[UserCreated, BearerIn, BearerOut](
"bearerAuth", bearerAuthScheme, nil,
func(ctx context.Context, msg *UserCreated, in BearerIn) (BearerOut, error) {
if !validToken(in.Token) {
return BearerOut{}, errors.New("invalid bearer token")
}
return BearerOut{GrantedScopes: map[string][]string{"bearerAuth": nil}}, nil
},
).WithSubscribeProperty(events.NewPropertyParam("Authorization", codex.String(),
func(in BearerIn) string { return in.Token },
func(in *BearerIn, v string) { in.Token = v },
))
userCreatedSub = userCreatedSub.SubscribeBoundMW(bm)
In's own topic/property merge fields decode declaratively from the
incoming message in BOTH classes — no manual extraction anywhere. Out
carries the SAME conventional GrantedScopes map[string][]string field
REST uses, read by the adapter via reflection and fed into the SAME
events.CheckScopes call the legacy Fn path used. This field must
be populated even when zero specific scopes are required — an Out{}
zero value (nil map) means NOTHING satisfies the scheme at all; return
Out{GrantedScopes: map[string][]string{"<schemeName>": nil}} for a
scheme with no scope requirements.
On the publish side, BoundPublishMiddleware's Fn shape is
func(ctx, msg T) (Out, error) (T BY VALUE) — recognized identically,
dispatched through the SAME merge-field mechanism instead of hand-building
MQTT5 User Properties.
A SubscribeBoundMW+PublishBoundMW pairing for the SAME scheme name on
ONE channel value is independent by construction (Subscriber/Publisher
are already separate values returned by Channel.WithSubscribe/
WithPublish) — no collision risk analogous to REST's own
HandleBoundMW+ClientBoundMW-same-route constraint.
The legacy raw-adapter credential-pairing mode
(SubscribeMW(&mw, rawFn)/PublishMW(&mw, rawFn) with a reflection-detected
bound-shaped fn) is PERMANENTLY CLOSED — SubscribeMW/PublishMW
now reject any codec-backed Middleware[In, Out] value outright
(MiddlewareMisattachedError), whether or not fn's shape looks bound.
mqtt v3's closure-captured-credential pattern re-expresses via the
reusable class unchanged (the credential capture moves into the
WithReceive closure, in simply goes unused since mqtt v3 has no
property channel to decode it from); ZeroMQ's in-payload pattern
re-expresses via the bound class. See
examples/events-api/handlers/security.go
and docs/design/d-0003-codec-declared-middlewares.md's Addendum 7's investigation for the
case-by-case migration mapping.
Connection-level auth spec registration — Client.AddConnectSecurityScheme¶
Runnable demo:
examples/events-api/demo_connect_security_scheme_registration.go— a scheme referenced ONLY viaAddServer(...).Security, proven to appear incomponents/securitySchemesdespite zero channels referencing it.
A connection-level scheme (the broker's OWN CONNECT-time credential —
see "Connection-level vs message-level security" above) referenced ONLY
via a Server.Security list, never by any individual channel's own
Subscribe/Publish requirement, previously had no way to be spec-registered
at all (components/securitySchemes was aggregated ONLY from
per-channel declarations). Client.AddConnectSecurityScheme closes this:
eventsClient.AddConnectSecurityScheme("brokerAuth", events.SecurityScheme{
Type: events.SecuritySchemeHTTP, Scheme: "basic",
})
eventsClient.AddServer("mqtt5", events.Server{
URL: "mqtts://broker:8883", Protocol: "mqtt5",
Security: []events.SecurityRequirement{events.Require("brokerAuth")},
})
// ... later, at ATTACH time — real credentials handed to the adapter,
// a real broker-rejection error surfaces BEFORE Client.Attach is reached.
client, router, err := mqtt5.Connect(ctx, "broker:8883", mqtt5.ConnectOptions{
ClientID: "svc-1", Username: username, Password: password,
Observer: obs, // optional — reports a broker-rejected CONNECT via RecordSecurityRejection
})
if err != nil {
var connErr mqtt5.ConnectError
if errors.As(err, &connErr) {
// connErr.ReasonCode/ReasonString are populated when the broker
// actually sent a CONNACK (e.g. 0x86 "Bad username or password") —
// zero-value for a dial-stage failure.
}
return err
}
Reuse the SAME scheme NAME at both call sites — the primary (and today only) safety mechanism linking the declared spec entry to the real credentials supplied at attach time; see D-0006 for the full declare/attach-time-supply lifecycle this capability follows.
Security for request-reply routes (reqreply)¶
api/reqreply mirrors REST's exact declare-once, enforce-symmetrically
model on BOTH transports it supports — see
docs/design/d-0003-codec-declared-middlewares.md's Addendum 7 for the full design this
section documents. A codec-backed reqreply.Middleware[In, Out] (built
via reqreply.SecurityMiddleware[In, Out]) can be attached in ONE of two
ways, depending on whether the Fn needs access to the route's own
decoded request struct:
Reusable class — Middleware[In, Out], attached via .Use(). The
Fn is embedded via WithReceive/WithSend and is Req-free — the
default choice whenever the credential check only needs a decoded
topic/property value, never the request/response struct itself.
Unlike api/events, reqreply's WithReceive ALREADY returns (Out,
error) (symmetric with REST) — so this class CAN populate
GrantedScopes normally; no api/events-style structural limitation
exists here:
var bearerAuthCodec = codex.String().Refine(validate.BearerToken)
type BearerIn struct{ Token string }
type BearerOut struct{ GrantedScopes map[string][]string }
bearerMw := reqreply.SecurityMiddleware[BearerIn, BearerOut]("bearerAuth",
reqreply.SecurityScheme{SecurityScheme: reqreply.BearerScheme("JWT")}.WithCodec(bearerAuthCodec), nil,
).WithRequestProperty(reqreply.NewPropertyParam("Authorization", codex.String(),
func(in BearerIn) string { return in.Token },
func(in *BearerIn, v string) { in.Token = v },
)).WithReceive(func(ctx context.Context, in BearerIn) (BearerOut, error) {
if !validToken(in.Token) {
return BearerOut{}, errors.New("invalid bearer token")
}
return BearerOut{GrantedScopes: map[string][]string{"bearerAuth": nil}}, nil
})
securedRoute := reqreply.NewRoute[ComputeReq, ComputeResp](
"compute/add", computeReqCodec, computeRespCodec,
reqreply.RouteMeta{OperationID: "computeAdd"},
).Use(bearerMw)
Bound class — BoundMiddleware[Req, In, Out]/BoundClientMiddleware[Req, In, Out],
attached via HandleBoundMW/ClientBoundMW. Fn additionally receives
*Req (server) or Req by value (client) — use this when a credential
check genuinely needs to READ the route's own decoded request struct
directly — mqtt5 has a property side channel (User Properties), so this
is rarely needed there; zeromq has NO property/header side channel at
all, so its credential model is ALWAYS in-payload, making the SERVER
side of BoundMiddleware the genuinely necessary mechanism for zeromq
Security, not merely a convenience:
type ComputeReq struct{ X, Y int; Token string }
securedRoute := reqreply.NewRoute[ComputeReq, ComputeResp](
"compute/add", computeReqCodec, computeRespCodec,
reqreply.RouteMeta{OperationID: "computeAdd"},
).HandleBoundMW(reqreply.BoundSecurityMiddleware[ComputeReq, struct{}, struct{}](
"bearerAuth", reqreply.SecurityScheme{SecurityScheme: reqreply.BearerScheme("JWT")}, nil,
func(ctx context.Context, req *ComputeReq, in struct{}) (struct{}, error) {
return struct{}{}, checkNotRevoked(req.Token)
},
))
BoundClientMiddleware's CLIENT-side limitation (confirmed, not a
bug): BoundClientMiddleware's Fn (func(ctx, req Req) (In, error))
can only DERIVE a wire-level topic/property value FROM req (its
returned In is encoded into topic/property vars only, same as the
reusable class) — it has NO mechanism to write a value back into req's
own body/payload fields, on ANY transport. For zeromq's in-payload
credential model specifically, this means there is nothing useful for a
client-side BoundClientMiddleware/BoundSecurityClientMiddleware
attachment to do — the credential field is simply part of the ordinary
request the caller constructs, exactly like any other field:
No client-side middleware is needed or useful for this case — Call's
caller supplies the credential the same way it supplies X/Y. Reserve
BoundClientMiddleware for cases where a wire-level value genuinely
needs to be COMPUTED from req (e.g. a signature derived from req.X/
req.Y, sent as an MQTT5 User Property) — mirroring
NewTenantPropertyMw's real pattern (see examples/reqreply-api), not
an in-payload credential.
The application's own domain Req struct must carry a credential field
itself for zeromq's model to work (Token string above) — a documented,
accepted transport limitation (zeromq has no property/header side
channel), not a bug.
In's own topic/property merge fields decode declaratively from the
incoming request in BOTH classes — no manual extraction anywhere. Out
carries the SAME conventional GrantedScopes map[string][]string field
REST/events use, read by the adapter via reflection and fed into the
SAME reqreply.CheckScopes call — RECEIVING (Serve)-side only
(Call/sending-side needs no merge wiring, same as REST's ClientMW).
This field must be populated even when zero specific scopes are
required — an Out{} zero value (nil map) means NOTHING satisfies the
scheme at all; return Out{GrantedScopes: map[string][]string{"<schemeName>": nil}}
for a scheme with no scope requirements.
A HandleBoundMW+ClientBoundMW pairing for the SAME scheme name on
ONE shared Route value CANNOT be attached — reqreply's Route[Req,Resp]
is ONE shared value carrying BOTH server and client attachments, exactly
like REST (unlike api/events' independent Subscriber/Publisher
values) — attempting this fails with DuplicateMiddlewareNameError
(the SAME constraint REST's own BoundMiddleware/BoundClientMiddleware
documents). Build TWO SEPARATE route values instead — one per role.
The legacy raw-adapter credential-pairing mode
(HandleMW(&mw, rawFn)/ClientMW(&mw, rawFn) with a reflection-detected
bound-shaped fn) is PERMANENTLY CLOSED — HandleMW/ClientMW now
reject any codec-backed Middleware[In, Out] value outright
(MiddlewareMisattachedError), whether or not fn's shape looks bound.
mqtt5's raw-message-reading pattern re-expresses via the reusable class
(property-decoded In, discarding *Req entirely); zeromq's in-payload
pattern re-expresses via the bound class, the ONE genuine case this
mechanism exists for.
Server.AddGlobalSecurity(reqs...) and per-route RouteMeta.Security
(nil=inherit global, empty=no auth) work identically to REST/events.
reqreply.SecurityCredentialError/reqreply.SecurityError are the
request-reply analogues of REST's error types — same fields, same
errors.As/slog.LogValuer shape. A route declaring a scheme with no
attached implementation satisfying it fails loudly at Register/Serve time
with reqreply.MissingSecurityMiddlewareError (reqreply.CheckCoverage,
mirrors rest.CheckCoverage exactly) — never a silent no-op; a
HandleMW/HandleBoundMW implementation naming a scheme never .Use()'d
on the same route fails with reqreply.UnknownMiddlewareImplementationError
— the reverse-direction sibling.
User Property param-as-middleware: reqreply.Middleware[In,Out]'s
WithRequestPropertySpec/WithResponsePropertySpec (also available on
BoundMiddleware/BoundClientMiddleware) declare a presence-only
(non-merged) User Property param — a header-like param declaration with
no corresponding In/Out struct field to decode into. No
HandleBoundMW/ClientBoundMW pairing needed (unlike security schemes,
header params aren't gated behind CheckCoverage) — declaring .Use(...)
is enough for mqtt5's server-transport dispatch to validate the real
MQTT5 User Property automatically, and for the property to render into
the request/reply message's AsyncAPI headers schema:
var apiKeyParam = reqreply.PropertyParam{Param: codex.Param{Name: "X-API-Key"}, Required: true}
route := ComputeRoute.Use(
reqreply.NewMiddleware[struct{}, struct{}](reqreply.Declaration[struct{}, struct{}]{
Name: "declare-api-key-property",
}).WithRequestPropertySpec(apiKeyParam),
)
Feature: Codec-Declared Middleware's
reqreply.Middleware[In,Out]'s WithRequestProperty/WithResponseProperty
axis is this mechanism's MERGE-capable sibling — required vs. optional
properties are a first-class choice there too
(NewPropertyParam/NewOptionalPropertyParam), the difference being
whether a decoded In/Out struct field exists to merge into.
Connection-level auth spec registration — Server.AddConnectSecurityScheme¶
Runnable demo:
examples/reqreply-api/demo_connect_security_scheme_registration.go— the same proof as events' demo above, forreqreply.Server.
Mirrors events.Client.AddConnectSecurityScheme byte-for-byte (Builder
is a deprecated alias for Server — the method lives on Server, both
names pick it up):
reqreplyServer.AddConnectSecurityScheme("brokerAuth", reqreply.SecurityScheme{
Type: reqreply.SecuritySchemeHTTP, Scheme: "basic",
})
reqreplyServer.AddServer("mqtt5", reqreply.ServerEntry{
URL: "mqtts://broker:8883", Protocol: "mqtt5",
Security: []reqreply.SecurityRequirement{reqreply.Require("brokerAuth")},
})
Reuse the SAME scheme NAME at both call sites, same lifecycle as events' version above — see D-0006.
Sharing a security SCHEME across REST/events/reqreply¶
Each of rest/events/reqreply has its own SecurityMiddleware[In, Out
any](schemeName, scheme, scopes) Middleware[In, Out] constructor — the one,
single vocabulary for declaring a security requirement in that pattern.
rest.SecurityMiddleware is generic over In/Out (docs/roadmap/
declarative-middleware-layering.md's Rollout Phase A — away from a
hardcoded Middleware[struct{}, struct{}]), so a caller needing the
credential-providing fn to ALSO decode request header/cookie/query merge
fields can do so directly; the pure credential-carrier pattern (no
merge fields) passes [struct{}, struct{}] explicitly (Go cannot infer
In/Out here — no parameter is typed by them):
rest.SecurityMiddleware[struct{}, struct{}]("basicAuth", scheme, nil).
These constructors are pattern-specific BY DESIGN (their return types
differ: rest.Middleware[In,Out], events.Middleware[struct{},struct{}],
reqreply.Middleware[struct{},struct{}] are three distinct Go types, and a
value of one does NOT work if attached to another pattern's routes/channels —
each pattern's internal dispatch only recognizes its own concrete type; a
foreign-pattern value would compile but be silently dropped, contributing
nothing to that pattern's spec).
What genuinely IS shared, and is the actual mechanism behind "one OAuth2
scheme, usable everywhere": the underlying security-scheme spec value
(BearerScheme/BasicScheme/APIKeyScheme/OAuth2Scheme/
OpenIDConnectScheme — identically-aliased across rest/events/
reqreply, so any one pattern's constructor works) and the
credential-format codex.Codec[string], both of which are already
protocol-agnostic, ordinary Go values with no per-pattern type at all.
Declare these ONCE, then pass the SAME values into each pattern's own
SecurityMiddleware constructor — "one shared config, one declaration
per pattern":
// ONE shared, protocol-agnostic config — not pattern-specific (rest/
// events/reqreply's own OAuth2Scheme/OAuthFlows/OAuthFlow are identical
// aliases, so using rest's here is an arbitrary, interchangeable choice).
var oauthScheme = rest.OAuth2Scheme(rest.OAuthFlows{
ClientCredentials: &rest.OAuthFlow{
TokenURL: "https://auth.example.com/oauth2/token",
Scopes: map[string]string{"compute:write": "Submit compute requests"},
},
}).SecurityScheme
var oauthScopes = []string{"compute:write"}
// Three pattern-specific declarations, same underlying scheme + scopes:
restMw := rest.SecurityMiddleware[struct{}, struct{}]("oauth2Compute", rest.SecurityScheme{SecurityScheme: oauthScheme}.WithCodec(oauthCodec), oauthScopes)
eventsMw := events.SecurityMiddleware[struct{}, struct{}]("oauth2Compute", events.SecurityScheme{SecurityScheme: oauthScheme}.WithCodec(oauthCodec), oauthScopes)
reqreplyMw := reqreply.SecurityMiddleware[struct{}, struct{}]("oauth2Compute", reqreply.SecurityScheme{SecurityScheme: oauthScheme}.WithCodec(oauthCodec), oauthScopes)
restRoute := rest.NewRoute[Req, Resp]("POST", "/compute", reqCodec, respCodec, meta).Use(restMw)
channel := events.NewChannel[Msg]("compute/events", codec, meta).WithSubscribe(events.Subscribe{}).Use(eventsMw)
reqreplyRoute := reqreply.NewRoute[Req, Resp]("compute/add", reqCodec, respCodec, meta).Use(reqreplyMw)
Each of the three specs (Server.OpenAPISpec()/Client.AsyncAPISpec()/
Server.AsyncAPISpec()) renders an IDENTICAL securitySchemes.
oauth2Compute entry (same type, flows, scopes) — because all three
declarations were built from the SAME oauthScheme/oauthScopes/
oauthCodec values, even though each is its own distinct
Middleware[struct{},struct{}] instance. examples/reqreply-api's Demo 9
(demo_cross_api_oauth2_sharing.go) demonstrates this concretely:
routes.OAuthMwReqreply is attached to a REAL, served, called zeromq
reqreply route, and routes.OAuthMwREST (same rest.SecurityScheme
config) is attached to a locally-declared REST route (registered just to
print its OpenAPISpec() output) — the demo then prints both specs'
oauth2Compute entries side by side to show they're byte-for-byte
identical, even though the two Middleware values attached to get there
are not the same Go value.
What is NOT shared: the paired implementation Fn. HandleMW/
ClientMW/SubscribeMW/PublishMW's attached Fn is adapter-specific —
each transport's paired security shape differs (REST: func(ctx,
*http.Request, *Req) (map[string][]string, error); mqtt5: func(ctx,
msg *pahomqtt5.Publish, ...) (map[string][]string, error); zeromq:
func(ctx, *Req, reqs) error, no scope-grant). You cannot pass the
literal same Go closure to HandleMW calls on two different transports.
The recommended pattern: factor the REAL verification logic (token introspection, JWT validation, scope extraction) into ONE shared, transport-agnostic helper taking a plain credential string, then write a THIN, few-line wrapper Fn per transport that only differs in HOW it extracts the raw credential from its own transport shape (HTTP header / MQTT User Property / decoded request field) before calling the shared helper:
// ONE shared helper — the actual verification logic, transport-agnostic.
func VerifyOAuth2Scopes(token string) (map[string][]string, error) { /* ... */ }
// THIN, zeromq-shaped wrapper — extracts the token from *Req, delegates.
func zeromqOAuthFn(_ context.Context, req *ComputeReq, _ []reqreply.SecurityRequirement) error {
scopes, err := VerifyOAuth2Scopes(req.Token)
if err != nil {
return err
}
return reqreply.CheckScopes(reqs, scopes)
}
// THIN, REST-shaped wrapper — extracts the token from *http.Request, delegates.
func restOAuthFn(_ context.Context, r *http.Request, _ *ComputeReq) (map[string][]string, error) {
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
return VerifyOAuth2Scopes(token)
}
This is exactly examples/reqreply-api/handlers/oauth.go's structure:
VerifyOAuth2Scopes is the one shared helper; VerifyOAuthComputeZeroMQ
is its zeromq-shaped wrapper.
SecurityObserver — rejection metrics¶
Implement stats.SecurityObserver on your observer to receive rejection events. Adapters type-assert it — no breaking change to the Observer interface:
type TelemetryObserver struct {
stats.NoopObserver // embed for Observer methods
}
func (o *TelemetryObserver) RecordSecurityRejection(location, scheme string) {
// location = route path (HTTP) or topic (MQTT)
// scheme = first declared security scheme name for the operation
metrics.SecurityRejections.WithLabelValues(location, scheme).Inc()
}
OpenAPI / AsyncAPI output¶
Security schemes appear in components/securitySchemes; global security at document root (REST only — AsyncAPI 3.0 has no document-level global security field); per-operation security overrides inline — all generated automatically from each route/channel's own security declaration (REST: rest.SecurityMiddleware attached via Route.Use(); events: events.SecurityMiddleware attached via Subscriber.Use()/Publisher.Use() — mirrors REST exactly (events.WithSecurityScheme is the older, deprecated, channel-level mechanism, kept only for backward compatibility); reqreply: reqreply.SecurityMiddleware(...) attached via Route.Use() — mirrors REST/events exactly (reqreply.WithSecurityScheme is the older, deprecated, route-level mechanism, kept only for backward compatibility, same treatment as events.WithSecurityScheme); aggregated by Server.OpenAPISpec/Client.AsyncAPISpec, last-registered-wins on name collision) / AddGlobalSecurity / RouteMeta.Security / Subscribe.Security / Publish.Security. No manual YAML needed.
See also¶
- Guide: HTTP Server —
SecurityFuncwiring in the adapter - Guide: HTTP Client —
CredentialFuncfor client-side credentials - Guide: Observer —
SecurityObservermetrics - examples/rest-api — bearer JWT + scopes + observer, both chi and net/http adapters
- examples/reqreply-api — Demo 9 (
demo_cross_api_oauth2_sharing.go) shows ONESecurityScheme(OAuth2) config shared, declared twice, across a zeromq reqreply route AND a locally-declared REST route