securex

package
v0.24.0 Latest Latest
Warning

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

Go to latest
Published: Sep 28, 2026 License: Apache-2.0 Imports: 41 Imported by: 0

Documentation

Overview

Code generated by apic; DO NOT EDIT.

Code generated by apic; DO NOT EDIT.

Code generated by apic; DO NOT EDIT.

Code generated by apic; DO NOT EDIT.

Code generated by apic; DO NOT EDIT.

Code generated by apic; DO NOT EDIT.

Code generated by apic; DO NOT EDIT.

Code generated by apic; DO NOT EDIT.

Code generated by apic; DO NOT EDIT.

Code generated by apic; DO NOT EDIT.

Package securex — OIDC asymmetric verifier.

VerifyJWTWithJWKS is the resource-server counterpart to signerx.SignJWT. It is the runtime piece that closes the GENERATOR_BUGS.md "OIDC capability gap" — see Recommended generator work step (1) "Port/adapt htpx/oidc.go JWKS verification into the securex verifier". Specifically:

  • Accepts asymmetric algorithms only (RS256/384/512, ES256/384/512, PS256/384/512). The `none` algorithm and every HMAC family are rejected unconditionally — RFC 7518 alg confusion is the primary practical OIDC attack class and the verifier MUST refuse to even attempt HMAC verification under an asymmetric profile.
  • Honors the same Require{Expiry,Issuer,Audience} options that VerifyJWTWithOptions does, so OIDC profiles get strict-claim enforcement by default.
  • Delegates signature verification to jwx's jwt.Parse, which is the same library that signerx.SignJWT signs with.

Code generated by apic; DO NOT EDIT.

Code generated by apic; DO NOT EDIT.

Code generated by apic; DO NOT EDIT.

Code generated by apic; DO NOT EDIT.

Code generated by apic; DO NOT EDIT.

Code generated by apic; DO NOT EDIT.

Index

Constants

View Source
const DevEnvVar = "APIC_ENV"

DevEnvVar names the explicit-environment signal (A-03). The insecure-dev bypass refuses to engage unless this is set to "development" (case insensitive), so that debug mode alone — which is gin's DEFAULT and ships by omission unless GIN_MODE=release — can never silently enable it. Operators must make a positive development assertion.

View Source
const GenericForbiddenCode = "forbidden"

GenericForbiddenCode is the machine-readable `code` value securex writes in the ApiError envelope on every 403. Sibling of GenericUnauthorizedCode — same rationale, same generic-token contract for downstream regression tests. Pentest follow-up A-005 (2026-05-24, LOW): the F-CR-004 closure scrubbed the 401 surface but the 403 paths in RequireRole / RequireABAC and the generator template's role/scope/attribute branches still rendered `securex: insufficient role` verbatim, leaving the same fingerprinting surface for any authenticated-but-under-privileged caller. This constant + WriteForbidden close that gap.

View Source
const GenericPayloadTooLargeCode = "payload_too_large"

GenericPayloadTooLargeCode is the machine-readable `code` value securex writes in the ApiError envelope on the HMAC body-size 413 path. Sibling of GenericUnauthorizedCode / GenericForbiddenCode — same generic-token contract so downstream regression tests can assert the code without matching the volatile requestId field. Matches the generator's errorCodeForStatus(413) token so the HMAC 413 and the REST/JSON 413 surfaces are byte-for-byte identical.

Pentest follow-up F-CR-004 / A-005 hardening (2026-09-20): the HMAC body-too-large path previously wrote `{"error":"securex: request body exceeds signing limit: body exceeds 1048576 bytes","error_code":413}`, which leaked BOTH the framework name and the exact configured byte limit. This constant + the generic writeHMACErrorEnvelope close that gap; the sentinel (and the exact maxBodyBytes) go to the audit sink only.

View Source
const GenericServiceUnavailableCode = "service_unavailable"

GenericServiceUnavailableCode is the machine-readable `code` value WriteServiceUnavailable writes in the ApiError envelope: generic and framework-neutral like GenericUnauthorizedCode.

View Source
const GenericUnauthorizedCode = "unauthenticated"

GenericUnauthorizedCode is the machine-readable `code` value securex writes in the ApiError envelope on every 401. It deliberately omits the `securex:` framework prefix so an unauthenticated caller cannot fingerprint the auth-middleware library from the response payload alone. Pinned to the literal string so downstream regression tests (in apic and every consumer) can assert the code token without matching the volatile requestId field.

SONNY-1840: WriteUnauthorized now emits the spec ApiError envelope `{"code","message","requestId"}` instead of the legacy bare `{"error":"unauthenticated"}` body. The generic, framework-neutral code preserves the property below.

Pentest finding F-CR-004 (2026-05-24, LOW). Prior to the fix, the generator's writeJSONError helper rendered ErrAuthFailed.Error() verbatim — i.e. `{"error":"securex: authentication failed"}` — which gave the pentester a free fingerprint for the auth middleware. The sentinel error values themselves still carry the `securex:` prefix so operator log triage stays natural; the prefix simply never reaches the wire on the 401 path.

View Source
const InsecureDevEnv = "APIC_INSECURE_DEV"

InsecureDevEnv is the canonical environment variable that opts into the development-only insecure bypass flow. Callers should never rely on this being set in production.

View Source
const InvalidTokenChallenge = `Bearer realm="api", error="invalid_token", error_description="The access token is invalid"` //nolint:gosec // G101 false positive: an auth scheme and fixed parameters, not a credential

InvalidTokenChallenge is the WWW-Authenticate challenge WriteBearerUnauthorized sends when the plain jwt verifier refused a Bearer token the request did present: RFC 6750 §3.1 invalid_token with its fixed description. It is exactly what pkg/oidcx renders for Challenge{Error: invalid_token, ErrorDescription: DescriptionInvalidToken} with no resource metadata; securex does not import pkg/oidcx, so the value is a literal here and pkg/oidcx's tests pin the two together.

View Source
const MaxJWTBytes = 16 << 10

MaxJWTBytes bounds the compact serialization of a token VerifyJWTWithJWKS will parse, and pkg/oidcx's resource verifier refuses a larger token before it decodes anything. A JWT access token is a few KiB even with many claims; 16 KiB leaves room for large group or role lists while keeping the base64 and JSON decoding an unauthenticated request can trigger small and bounded.

View Source
const MinimalBearerChallenge = `Bearer realm="api"` //nolint:gosec // G101 false positive: an auth scheme and realm, not a credential

MinimalBearerChallenge is the WWW-Authenticate challenge a 401 from WriteUnauthorizedChallenge or WriteChallenge carries when the challenge it was given is empty or refused: RFC 9110 §15.5.2 requires every 401 to send at least one challenge, and a bare 401 tells a client nothing about how to authenticate. It is a Bearer challenge (RFC 6750 §3) with only the realm, exactly what pkg/oidcx renders for a challenge with nothing else to say (oidcx.Challenge{}.Header(), realm oidcx.ChallengeRealm). securex does not import pkg/oidcx, so the value is a literal here and pkg/oidcx's tests pin the two together. It names no framework, carries no cause and never varies with configuration (F-CR-004 / A-005).

Variables

View Source
var (
	ErrInvalidConfig = errors.New("securex: invalid configuration")
	ErrAuthFailed    = errors.New("securex: authentication failed")
	// ErrRateLimited's user-facing text deliberately OMITS the `securex:`
	// framework prefix. Unlike the auth sentinels (whose prefix is scrubbed
	// at the write site by WriteUnauthorized/WriteForbidden), ErrRateLimited
	// .Error() is rendered verbatim as the ApiError `message` on the 429 path
	// (generator writeJSONError, api.go.tmpl). Keeping the prefix here would
	// fingerprint the rate-limiter library on the wire (F-CR-004 / A-005).
	// errors.Is callers are unaffected — matching is identity-based, not
	// substring-based.
	ErrRateLimited      = errors.New("rate limit exceeded")
	ErrInsufficientRole = errors.New("securex: insufficient role")
	// ErrOwnershipDenied is returned (server-side, in audit logs) when an
	// object-level authorization check fails — i.e. the authenticated
	// subject does not own the requested resource (SEC-0027 BOLA defense).
	// The wire response is a generic 403 via WriteForbidden; the sentinel
	// is logged only.
	ErrOwnershipDenied = errors.New("securex: object ownership denied")
	// ErrInsufficientAMR is returned (server-side, in audit logs) when a
	// request reaches a webauthnx.RequireAMR-gated route without the
	// required RFC 8176 Authentication Methods References claim — i.e.
	// the bearer token authenticated but the principal did not complete
	// the required step-up method (typical value: "webauthn"). The wire
	// surface is the generic 403 envelope so the required AMR is never
	// disclosed to the caller. GENWA-R8.
	ErrInsufficientAMR = errors.New("securex: insufficient amr (step-up required)")
	// ErrBodyTooLarge is returned by HMAC verification paths when a request
	// body exceeds the per-call limit. Streaming the body through sha256
	// via io.LimitReader prevents an attacker from forcing the server to
	// buffer arbitrarily large payloads to be signed. (PERF-0012.)
	ErrBodyTooLarge = errors.New("securex: request body exceeds signing limit")

	// ErrAuthVerifierRequired is the bootstrap sentinel raised (wrapped in
	// a panic) by the generated RegisterGeneratedAPI / RegisterGeneratedWS
	// calls when the configuration declares at least one route with
	// auth: "jwt" but the caller supplied a nil JWT verifier in
	// APIOptions.AuthJWT / WSOptions.AuthJWT. Closing GAP-0076: previously
	// a missing verifier silently 401'd every request, which masked
	// configuration errors. The generator now panics at registration so
	// the misconfiguration cannot ship. Use pkg/securex.NewTestVerifier
	// in unit-test harnesses where you want to accept any non-empty
	// Bearer.
	ErrAuthVerifierRequired = errors.New("securex: AUTH_VERIFIER_REQUIRED: apic requires a JWT verifier when any route declares auth: \"jwt\" (set APIOptions.AuthJWT / WSOptions.AuthJWT; see pkg/securex.NewTestVerifier for the test escape hatch)")

	// ErrResourceVerifierRequired is the registration-time panic of a
	// generated RegisterGeneratedAPI / RegisterGeneratedWS /
	// RegisterGeneratedGQL whose config enables security.resource_server
	// and has a jwt route (or a GraphQL endpoint), when the options carry
	// no ResourceVerifier: every Bearer token would be refused instead of
	// the server failing at boot (SONNY-794).
	ErrResourceVerifierRequired = errors.New("securex: RESOURCE_VERIFIER_REQUIRED: apic requires the security.resource_server verifier when any route has a jwt leaf and the resource server is enabled (set APIOptions.ResourceVerifier / WSOptions.ResourceVerifier; the generated Serve wires it)")

	// ErrAuthAPIKeyRequired mirrors ErrAuthVerifierRequired for routes that
	// declare auth: "api_key". The generator refuses to register such a
	// route when APIOptions.Auth / WSOptions.Auth is nil. GAP-0076.
	ErrAuthAPIKeyRequired = errors.New("securex: AUTH_API_KEY_REQUIRED: apic requires an API-key verifier when any route declares auth: \"api_key\" (set APIOptions.Auth / WSOptions.Auth; see pkg/securex.NewTestVerifier for the test escape hatch)")

	// ErrAuthWebhookRequired is returned by the generated boot-time guard
	// when a route declares auth="webhook" but the consumer-supplied
	// APIOptions.AuthWebhook is nil. Fail-closed: refuse to start rather
	// than silently accept every webhook delivery.
	ErrAuthWebhookRequired = errors.New("securex: APIOptions.AuthWebhook is nil but at least one route declares auth=webhook")

	// ErrAuthMTLSRequired is the bootstrap sentinel raised (wrapped in a
	// panic) by the generated RegisterGeneratedAPI when the configuration
	// declares at least one route with auth: "mtls" but the caller supplied
	// a nil APIOptions.AuthMTLS hook. securex.VerifyMTLS deliberately never
	// enforces SupportedIssuers (only opts.AuthMTLS enforces issuer/identity
	// policy), so a nil hook fails OPEN — accepting any valid CA-chain client
	// cert regardless of issuer label. Mirrors ErrAuthVerifierRequired /
	// ErrAuthWebhookRequired: the generator panics at registration so the
	// misconfiguration cannot ship.
	ErrAuthMTLSRequired = errors.New("securex: AUTH_MTLS_REQUIRED: apic requires an mTLS verifier when any route declares auth: \"mtls\" (set APIOptions.AuthMTLS; the generated server installs server.RequireClientCert / server.IssuerCNVerifier as secure defaults — see WithAuthMTLS)")

	// ErrMTLSRuntimeRequired is raised (wrapped in a panic) by the generated
	// RegisterGeneratedAPI when the configuration declares at least one
	// route whose mtls block sets crl/ocsp/cac_piv/principal_mapping (L-51)
	// but the caller supplied a nil APIOptions.MTLSRuntimes map. Unlike
	// AuthMTLS, MTLSRuntimes is populated automatically by server.Serve
	// (server_lib.go.tmpl constructs it once at boot from the same config
	// that declared these fields) — this guard exists for hand-built
	// APIOptions callers (custom entrypoints, tests) that would otherwise
	// silently get NO CRL/OCSP/CAC-PIV enforcement despite the config
	// asking for it, reproducing the exact false-assurance bug L-51 fixes.
	ErrMTLSRuntimeRequired = errors.New("securex: MTLS_RUNTIME_REQUIRED: apic requires APIOptions.MTLSRuntimes when any route's mtls block declares crl/ocsp/cac_piv/principal_mapping (server.Serve wires this automatically; hand-built APIOptions must set it explicitly or drop those fields)")

	// ErrCSRFSignerRequired is raised (wrapped in a panic) by the generated
	// RegisterGeneratedAPI boot guard when the configuration enables
	// security.csrf but the caller supplied a nil APIOptions.CSRF signer.
	// Fail-closed: refuse to start rather than silently skip CSRF
	// enforcement on cookie-authenticated state-changing requests. F2.
	ErrCSRFSignerRequired = errors.New("securex: CSRF signer required")
)

Sentinel errors for security and auth. Kept at package level per repo conventions. Messages use a lowercase, package-prefixed, diagnostic form so log output reads naturally; errors.Is callers should rely on pointer equality rather than substring matching of Error().

View Source
var (
	ErrMTLSCertMissing     = errors.New("securex: client certificate missing")
	ErrMTLSCertNotVerified = errors.New("securex: client certificate not verified")
	ErrMTLSCertEKUMissing  = errors.New("securex: client cert lacks clientAuth EKU")
	// ErrMTLSNoIssuer is returned when a route's MTLSPolicy.Runtime configures
	// a CRL or OCSP RevocationChecker but the verified chain carries no
	// issuer certificate to check the leaf against (r.TLS.VerifiedChains[0]
	// has fewer than two entries). This should not happen once a listener
	// has actually verified the peer chain; it fails closed rather than
	// skipping the revocation check.
	ErrMTLSNoIssuer = errors.New("securex: mtls: no issuer certificate in verified chain for revocation check")
	// ErrMTLSPrincipalControl is returned when the principal a route's
	// CertPolicy resolved from the certificate (or its classification)
	// carries a control, bidirectional or invisible character
	// (HasIdentityControl).
	ErrMTLSPrincipalControl = errors.New("securex: mtls: the certificate principal carries a control character")
)

Sentinel errors raised by VerifyMTLS. Callers should match with errors.Is rather than substring-matching the messages.

View Source
var (
	// ErrNoClientCertificate is returned by WorkloadVerifier.Verify for a
	// request that arrived without a TLS client certificate the listener
	// verified: no TLS, no peer certificate, or no verified chain.
	ErrNoClientCertificate = errors.New("securex: no verified TLS client certificate")
	// ErrWorkloadNotAllowed is returned for a verified certificate whose
	// workload the configuration does not accept: a SPIFFE ID outside every
	// trusted trust domain and not listed in allowed_spiffe_ids, a SPIFFE ID
	// that names a trust domain rather than a workload (no path), or no
	// usable subject in the field subject_from selects (empty, or holding a
	// control, bidirectional or invisible character).
	ErrWorkloadNotAllowed = errors.New("securex: the client certificate's workload is not allowed")
	// ErrSPIFFEMalformed is returned for a certificate that does not carry
	// exactly one URI SAN, or whose URI SAN is not a SPIFFE ID
	// (ParseSPIFFEID), and by ParseSPIFFEID itself. It also wraps
	// configx.ErrInvalidSPIFFEID where the grammar refused the ID.
	ErrSPIFFEMalformed = errors.New("securex: malformed SPIFFE ID")
	// ErrWorkloadAuthDisabled is returned by NewWorkloadVerifier for an
	// absent or disabled workload_auth block, and by the methods of a nil
	// *WorkloadVerifier: there is nothing to verify a workload against.
	ErrWorkloadAuthDisabled = errors.New("securex: workload_auth is not enabled")
	// ErrWorkloadVerifierRequired is raised (wrapped in a panic) by the
	// generated RegisterGeneratedAPI, and the generated GraphQL resolver
	// constructors, when the server was generated with
	// security.resource_server.workload_auth but the caller supplied no
	// WorkloadVerifier: its certificate leaves would otherwise accept any
	// certificate the TLS layer verified. server.Serve wires it from
	// runtime.json.
	ErrWorkloadVerifierRequired = errors.New("securex: WORKLOAD_VERIFIER_REQUIRED: this server was generated with " +
		"security.resource_server.workload_auth, so APIOptions.WorkloadVerifier must be set (server.Serve builds it " +
		"with securex.NewWorkloadVerifier)")
	// ErrWorkloadTrustBundle is returned by NewWorkloadVerifier when a trust
	// domain the configuration accepts has no CA bundle to verify its
	// workloads against: a trust_bundles entry no WithTrustBundle supplied, a
	// WithTrustBundle for a trust domain the configuration does not accept,
	// or -- with more than one trust domain -- a trust domain without a
	// bundle. Every CA the listener trusts could otherwise assert every
	// trust domain.
	ErrWorkloadTrustBundle = errors.New("securex: workload trust bundle")
	// ErrClientCertificateMismatch is returned by ClientCertificateLeaf, and
	// so by WorkloadVerifier.Verify, when a verified chain of the TLS
	// connection does not start with the very certificate the peer
	// presented (PeerCertificates[0]): the workload check and the RFC 8705
	// binding would otherwise judge two different certificates (SONNY-794,
	// Task 26 fix round 2).
	ErrClientCertificateMismatch = errors.New("securex: the verified chain's leaf is not the presented client certificate")
)

Workload identity from a verified TLS client certificate (security.resource_server.workload_auth; SONNY-794, APIC-UP-007 Task 26).

View Source
var AllowedAsymmetricAlgs = []jwa.SignatureAlgorithm{
	jwa.RS256(), jwa.RS384(), jwa.RS512(),
	jwa.ES256(), jwa.ES384(), jwa.ES512(),
	jwa.PS256(), jwa.PS384(), jwa.PS512(),
	jwa.EdDSA(),
}

AllowedAsymmetricAlgs is the default alg allowlist for OIDC asymmetric verification. Callers may override via JWKSVerifyOptions.AllowedAlgs; in either case `none` and every HMAC alg remain hard-rejected.

View Source
var ErrClaimControlChar = errors.New("securex: subject/role/scope claim contains a control character")

ErrClaimControlChar is returned (wrapped in ErrAuthFailed by the verifiers) when a subject, role or scope claim string carries a C0 control character (U+0000..U+001F) or DEL (SEC-0081, #381; extended to sub by SEC-NEW2-05, #411). All three are matched exactly against operator-configured names or stored owner ids, so a control byte can never make a legitimate match; its only uses are as a cache-key or log-injection payload (mcpx's tools/list memoization joined roles on NUL, and sub reaches every audit sink and the CSRF session binding verbatim). Refusing the token at the claims-typing seam -- VerifyJWTWithClaims and ParseClaims, which every verifier including the JWKS path goes through -- makes "no control characters in an identity or authorization claim" an enforced invariant rather than an assumption. The subject and the tenant (attributes.tenant) are held to HasIdentityControl, which also refuses C1 controls and bidirectional formatting controls, since they name the caller in every audit record.

View Source
var ErrIdentityConflict = fmt.Errorf("%w: the credentials of one auth group name different identities", ErrAuthFailed)

ErrIdentityConflict reports an AND-group of a composite auth expression whose credentials name different identities: two leaves that install verified claims (jwt, cookie), or one of them and the identity already on the request when the group began (claims a verifier installed before EvalComposite ran), disagreeing on the subject or on attributes["tenant"], or either of them naming no subject at all (unless neither does and the two sets are equal, which they are only when both sides built their Claims the same way; see EvalComposite): a claim set with no subject cannot be shown to be the same principal as another, different one. It wraps ErrAuthFailed. EvalComposite reports it as the group's Bearer cause (BearerCause), so a route that accepts a Bearer token answers with it: 403 with no challenge (WriteBearerUnauthorized, pkg/oidcx's WriteError). Each credential may well be valid; the request is refused because they name two principals, so the client must not repeat it with the same credentials (RFC 9110 §15.5.4), and it must not discard its access token either, as an OAuth client does on invalid_token (then fetches another, which the same pairing refuses again).

View Source
var ErrJWKSEmpty = errors.New("securex: jwks empty (no keys to verify against)")

ErrJWKSEmpty is returned when the supplied jwk.Set has no keys. A live IdP outage that drained the cache is a separable failure mode from signature mismatch and worth surfacing distinctly to callers.

View Source
var ErrJWTExpired = errors.New("securex: jwt expired")

ErrJWTExpired is wrapped, beside ErrAuthFailed, by VerifyJWTWithJWKS's error for a token whose signature verified but whose "exp" has passed, so a resource server can tell a client its token expired (RFC 6750 §3.1 lets it say so) rather than that it is invalid. It is reported only after the signature verifies: an expired token under a bad signature is plain ErrAuthFailed, so the distinction cannot be probed with a forged token.

View Source
var ErrJWTSubjectRequired = errors.New("securex: jwt sub claim required")

ErrJWTSubjectRequired indicates JWTVerifyOptions.RequireSubject rejected a token whose "sub" claim is absent, non-string, or empty after trimming. SEC-0061: generated servers derive the CSRF session id from the verified "sub" claim (opts.CSRFSessionID); an empty/absent subject collapses every caller onto csrfx's "" binding, which Verify now refuses outright (csrfx.ErrCSRFUnbound). Requiring "sub" here stops the ambiguity at the JWT verifier instead of only at the CSRF layer.

View Source
var ErrJWTUnsupported = errors.New("securex: jwt algorithm or token unsupported")

ErrJWTUnsupported indicates VerifyJWT cannot handle the given algorithm/token.

View Source
var ErrNoBearerToken = fmt.Errorf("%w: no bearer token", ErrAuthFailed)

ErrNoBearerToken reports a request that presents no Bearer credentials: no Authorization header, or one with another scheme. It wraps ErrAuthFailed. A challenge for it carries no error code (RFC 6750 §3.1: a request that lacks authentication information gets none).

View Source
var ErrNoCookieToken = errors.New("securex: missing auth cookie")

ErrNoCookieToken indicates the named auth cookie was absent or empty.

View Source
var ErrNoSessionCookie = fmt.Errorf("%w: no session cookie", ErrAuthFailed)

ErrNoSessionCookie reports a request to a session-cookie gate (GateSessionCookie) that carries no session cookie. It wraps ErrAuthFailed.

View Source
var ErrVerifierMisconfigured = errors.New("securex: verifier misconfigured (Require* set without expected value)")

ErrVerifierMisconfigured is returned when the verifier options are internally inconsistent in a way that would silently weaken authentication. A-05: RequireIssuer/RequireAudience with no corresponding expected value (Issuer / Audience left empty) is a presence-only check that accepts ANY issuer/audience — it looks like a security control but enforces nothing. Rather than honour that footgun, the verifier fails closed with this configuration error so the misconfiguration is surfaced loudly at the first verification attempt.

View Source
var ErrWebhookFailed = errors.New("securex: webhook verification failed")

ErrWebhookFailed is returned by VerifyWebhook for every cause of rejection EXCEPT the body-cap case (which returns ErrBodyTooLarge directly so callers can map it to HTTP 413). The handler must NEVER include the wrapped reason in the response body — 401 responses go through WriteUnauthorized which writes the generic envelope. Internal reasons surface only via obsx.LogAudit.

Functions

func APIKeyAuth

func APIKeyAuth(r *http.Request, secret []byte, window time.Duration, seen *sync.Map) error

APIKeyAuth verifies Authorization: Bearer <hmac-hex> using a canonical request signature bound to method/path/body/nonce/timestamp and secret. It enforces a time window and replay protection via the seen map.

The request body is streamed through the hash via io.LimitReader with defaultHMACBodyLimit so that a single oversized request cannot exhaust server memory. See APIKeyAuthWithLimit for the explicit-limit variant.

func APIKeyAuthHTTP

func APIKeyAuthHTTP(w http.ResponseWriter, r *http.Request, secret []byte, window time.Duration, seen *sync.Map) bool

APIKeyAuthHTTP wraps APIKeyAuth with HTTP error mapping for PERF #171 (sign-or-413). On success it returns true and the caller continues dispatch with r.Body re-staged (pooledBuffer). On failure it writes the appropriate status (413 for ErrBodyTooLarge, 401 for any other auth error) and a JSON envelope carrying error_code = <status> so observability pipelines can route alerts off a structured field instead of regex-matching on Message.

The 413 status is reserved for the body-size condition; any other authentication failure (bad signature, missing headers, expired timestamp, replayed nonce, etc.) collapses to 401 with the same envelope shape. This is "Option B" from PERF_GAP_ANALYSIS row 13.

func APIKeyAuthHTTPWithLimit

func APIKeyAuthHTTPWithLimit(w http.ResponseWriter, r *http.Request, secret []byte, window time.Duration, seen *sync.Map, maxBodyBytes int64) bool

APIKeyAuthHTTPWithLimit is the explicit-limit variant of APIKeyAuthHTTP. A maxBodyBytes of 0 resolves to defaultHMACBodyLimit so that callers who have not migrated to an explicit cap still get the 1 MiB safe default. (PERF #171.)

func APIKeyAuthWithLimit

func APIKeyAuthWithLimit(r *http.Request, secret []byte, window time.Duration, seen *sync.Map, maxBodyBytes int64) error

APIKeyAuthWithLimit is the explicit-limit variant of APIKeyAuth. The maxBodyBytes parameter caps the number of bytes that will be read from r.Body when computing the canonical signature. A value of 0 resolves to defaultHMACBodyLimit. A body strictly larger than maxBodyBytes is rejected with an error that wraps ErrBodyTooLarge; callers can inspect via errors.Is. An empty secret is refused (ErrAuthFailed and ErrVerifierMisconfigured): the HMAC under an empty key is no secret.

func AllowInsecureDev

func AllowInsecureDev(r *http.Request, debugMode bool) bool

AllowInsecureDev reports whether the per-request INSECURE_DEV bypass should be honoured for this request. FOUR conditions must all hold (A-03):

  1. The TCP peer is a loopback address. The decision is made against r.RemoteAddr ONLY. X-Forwarded-For, X-Real-IP, and other proxy hints are intentionally ignored so that a reverse proxy cannot be tricked into forging a loopback origin.
  2. debugMode is true. The caller derives this from its own runtime mode (e.g. gin.Mode()==gin.DebugMode). Because debug is gin's DEFAULT (active unless GIN_MODE=release), debug mode alone is deliberately NOT sufficient — see condition 4.
  3. The environment variable APIC_INSECURE_DEV is set to a truthy value (1, true, yes, y, on - case insensitive).
  4. The environment variable APIC_ENV is set to "development" (case insensitive). This is the EXPLICIT positive development assertion that A-03 requires so the bypass cannot engage merely because debug mode shipped by omission.

If any condition fails, the bypass is denied. As a fail-closed guard, when APIC_INSECURE_DEV is truthy while the runtime is in release mode (GIN_MODE=release) the function PANICS — refusing to serve — rather than silently denying, because that combination is an unambiguous misconfiguration. A warning is logged at most once per process lifetime when the bypass first activates.

func AllowInsecureDevAtStartup

func AllowInsecureDevAtStartup(debugMode bool) bool

AllowInsecureDevAtStartup is the setup-time variant of AllowInsecureDev. No HTTP request is available yet, so only debugMode and the environment variable are checked. This is the correct helper for listener wiring, CORS configuration, and OIDC-missing startup branches - places where the per-request loopback check cannot yet run.

A-03: like AllowInsecureDev it refuses to start (panics) when APIC_INSECURE_DEV is truthy under release mode (GIN_MODE=release). The explicit APIC_ENV=development assertion is deliberately NOT required here: this helper governs the plain-HTTP-listener / TLS bypass (SEC-0002), a separate control from the per-request JWT-validation bypass that A-03 names. The release-mode hard-fail is the relevant fail-closed guard for the startup path; the stricter development-assertion gate is enforced on the per-request AllowInsecureDev, where the auth bypass actually decides a request.

func BearerCause added in v0.24.0

func BearerCause(err error) error

BearerCause returns why the Bearer credentials of a request failed, from the error a route's authentication returned, for the challenge the route answers with:

  • from a composite route (a *CompositeError), the error the first jwt leaf (GateJWT) that ran returned, or ErrIdentityConflict when a group failed first because its credentials named two identities (EvalComposite; answered 403 with no challenge); when neither happened (every group holding a jwt leaf failed on another leaf first), ErrNoBearerToken, so the challenge carries no error code;
  • any other error is returned as it is: a single-mode jwt route's verifier error is its Bearer cause.

func CallerFromRequest added in v0.23.0

func CallerFromRequest(r *http.Request) *callerctx.Caller

CallerFromRequest assembles the request-scoped caller from state that verifiers already installed on r's context: a caller a verifier mapped from a verified access token (ContextWithVerifiedCaller, pkg/oidcx's resource verifier), otherwise verified JWT claims (ClaimsFromContext) with the caller kind the verifier classified (ContextWithCallerKind); then the mTLS principal (MTLSPrincipalFromContext), and the credential transport (AuthSourceFromContext). Identity and authorization fields (Subject, Kind, Roles, Scopes, AMR, Tenant, Namespace, ACR, ClientID, Issuer, Delegation, Workload) come ONLY from those verifier-attested sources — never a request body, query parameter, or unverified header — so a client cannot assert its own identity, tenant, roles, or scopes. CorrelationID and RequestID are the exception: they are correlation identifiers, not authorization inputs, and may originate from a client-supplied header that has passed format validation upstream (obsx.ValidCorrelationID, the X-Request-ID convention) — never trust them for an authorization decision.

A verified caller is copied as the verifier mapped it (its Kind comes from security.auth.caller_kind_rules over the token's raw claims); only the correlation, request, trace and span ids are refreshed from r's context. It is used only while the Claims installed with it are still the ones on the context (VerifiedCallerFromContext): when a later verifier installed other claims (a composite route's cookie leaf after its jwt leaf, SONNY-794), the caller is built from those claims instead, so the caller a handler sees and the claims RBAC and ownership read never come from two different tokens.

From verified claims: Issuer, ClientID and ACR are the verified JWT's standard top-level `iss`/`client_id` (falling back to `azp`)/`acr` claims (Claims.Issuer/ClientID/ACR — trustworthy as soon as the signature verifies, independent of whether the verifier's JWTVerifyOptions additionally required/matched them). Tenant and Namespace have no standard claim name on this path, so they are populated only from the verified JWT's nested "attributes" claim (Claims.Attributes["tenant"/"namespace"]) — the same map RBAC/ownership checks already read via claims.Attributes[key]. Kind is what the verifier classified from the raw claims (ContextWithCallerKind; the generated plain jwt verifier applies security.auth.caller_kind_rules, or configx.DefaultCallerKindRules), and callerctx.KindUnknown when nothing classified them (a kind recorded for other claims does not count): no claim identifies a human portably across issuers, so reading any token with a sub as human (the rule before SONNY-794) would hand a service token a human's standing (SONNY-2684).

It never returns nil. Kind is KindUnknown for: a fully unauthenticated request; api_key or webhook auth (neither populates Claims); an mtls route with no CertPolicy configured (no principal to resolve); verified claims no verifier classified, or that no rule matched; and a JWT verified with an empty "sub" (the classifying verifiers make it unknown whatever the rules say). An empty-Subject-but-verified token still carries its Roles/Scopes/AMR/Attributes into the Caller — Kind alone reflects "no identifiable subject"; it does not discard what the token verified.

func CallerKindFromClaims added in v0.24.0

func CallerKindFromClaims(rules []configx.CallerKindRule, claims *Claims, raw map[string]any) callerctx.Kind

CallerKindFromClaims classifies the caller behind a token the plain jwt verifier verified, the way pkg/oidcx's resource verifier classifies its callers: the kind of the first rule in rules that matches the token's raw top-level claims (configx.ClassifyCaller; raw is what VerifyJWTWithClaimSet returns), else callerctx.KindUnknown, and always KindUnknown for a token with no sub, whatever the rules say. Pass security.auth's EffectiveCallerKindRules: the configured rules, or configx.DefaultCallerKindRules, which never yield a human. Only a rule list that passed configx.SecurityConfig.ValidateCallerKindRules is safe to apply; the generated Serve validates it at boot.

func CertificateUPN added in v0.24.0

func CertificateUPN(c *x509.Certificate) string

CertificateUPN returns the first UPN otherName in c's subjectAltName extension, or "" when there is none (or c is nil). Callers MUST call it only on a certificate whose chain has been verified. It is the one UPN parser: mtlsx.ExtractUPN (principal mapping, the cacpiv adapter) and the workload verifier's subject_from "upn" both use it.

func CertificateURISANs added in v0.24.0

func CertificateURISANs(c *x509.Certificate) []string

CertificateURISANs returns the uniformResourceIdentifier entries of c's subjectAltName extension exactly as the certificate encodes them, in order, or nil when there are none (or c is nil, or the extension does not parse). It is what the workload verifier reads a SPIFFE ID from, rather than x509.Certificate.URIs: those are url.URL values, and their String() re-serialises the URI -- a scheme lowercased ("SPIFFE://" reads back as "spiffe://"), an empty fragment or query dropped ("spiffe://td/x#" reads back as "spiffe://td/x") -- so a certificate the SPIFFE grammar refuses would pass as one it accepts. Callers MUST call it only on a certificate whose chain has been verified.

func ClientCertificateLeaf added in v0.24.0

func ClientCertificateLeaf(cs *tls.ConnectionState) (*x509.Certificate, error)

ClientCertificateLeaf returns the client certificate a TLS connection presented, PeerCertificates[0]: the one certificate every check of a request's client certificate reads (the workload identity, and the RFC 8705 thumbprint oidcx compares a bound token's cnf with). It fails closed with ErrNoClientCertificate for no connection or no peer certificate, and with ErrClientCertificateMismatch when any verified chain starts with a certificate that is not byte-for-byte (Raw) that one: crypto/tls always starts each chain with the presented leaf, so a mismatch is a hand-assembled or corrupted connection state, and neither certificate can be trusted to be the one the other check read.

func ContextWithAuthSource added in v0.18.3

func ContextWithAuthSource(ctx context.Context, src AuthSource) context.Context

ContextWithAuthSource returns a copy of ctx recording src as the credential transport a gate consumed for this request. Passing a nil ctx is safe and returns a context.Background()-derived value (mirrors ContextWithClaims).

func ContextWithCaller added in v0.23.0

func ContextWithCaller(ctx context.Context, c *callerctx.Caller) context.Context

ContextWithCaller is a thin re-export so generated code needs one import for the whole caller story. Stores a defensive copy of c (see callerctx.ContextWithCaller): use this whenever c is retained or reused after the call.

func ContextWithCallerKind added in v0.24.0

func ContextWithCallerKind(ctx context.Context, kind callerctx.Kind) context.Context

ContextWithCallerKind records kind, the caller kind a verifier classified from the raw top-level claims of the token it verified (configx .ClassifyCaller over security.auth.caller_kind_rules, or the configx.DefaultCallerKindRules), on ctx for CallerFromRequest, bound to the Claims already on ctx: call it AFTER ContextWithClaims, as the generated plain jwt verifier does (ContextWithCallerKind(ContextWithClaims(ctx, claims), kind)). The kind applies only while those Claims are the ones on the context, so a later leaf's claims never inherit an earlier leaf's kind. Without it, CallerFromRequest reads verified claims as callerctx.KindUnknown: nothing classified them, and no claim identifies a human portably.

func ContextWithClaims

func ContextWithClaims(ctx context.Context, claims *Claims) context.Context

ContextWithClaims returns a copy of ctx carrying the supplied Claims pointer. The generated per-route middleware first checks ClaimsFromContext(r.Context()) before re-parsing the bearer token, so a verifier (e.g. APIOptions.AuthJWT) that calls this once per request avoids the second base64-decode-and-Unmarshal per route. (S-NEW-3.)

Callers MUST only stash claims they have cryptographically verified (signature and expiry checked). Stashing claims from an unverified token is a security defect: downstream RBAC trusts these claims without re-checking the signature.

Passing a nil claims pointer stores a typed-nil so subsequent ClaimsFromContext(ctx) returns (nil, false) — this mirrors the "auth ran, but no claims attached" sentinel used by mcpx's ContextWithRoles when called with a nil slice.

Passing a nil ctx is safe and returns a context.Background()-derived value so downstream code can always derive a new ctx from the result.

func ContextWithMTLSPrincipal added in v0.18.3

func ContextWithMTLSPrincipal(ctx context.Context, principal, classification string) context.Context

ContextWithMTLSPrincipal returns ctx carrying the identity a route's CertPolicy verifier resolved for the verified client certificate: principal is the value selected by mtls.principal_mapping (subject_cn / upn / san_email / san_dns_first / edipi) and classification is the CAC/PIV classification (cacpiv.Classification.String()). VerifyMTLSRequest calls this ONLY after every check in the policy has passed; callers layering their own verifier on top should honor the same rule and never store an identity for a certificate that was not accepted.

func ContextWithMTLSWorkload added in v0.24.0

func ContextWithMTLSWorkload(ctx context.Context, w callerctx.Workload) context.Context

ContextWithMTLSWorkload returns ctx carrying the workload identity the workload verifier (WorkloadVerifier, security.resource_server.workload_auth) resolved for the verified client certificate, under the same key as ContextWithMTLSPrincipal: MTLSPrincipalFromContext reports w.Subject and w.Classification, and CallerFromRequest merges all of w into Caller.Workload (MTLSWorkloadFromContext). The workload is never installed as claims: that would retire a verified Bearer caller and enter the one-identity comparison. Call it only for a certificate every check accepted.

func ContextWithOwnedCaller added in v0.23.0

func ContextWithOwnedCaller(ctx context.Context, c *callerctx.Caller) context.Context

ContextWithOwnedCaller is a thin re-export of callerctx.ContextWithOwnedCaller for generated code's fast path: a wrapper that just built c via CallerFromRequest and does not retain or mutate c afterward can skip the defensive clone ContextWithCaller performs. See callerctx.ContextWithOwnedCaller's doc for the ownership obligation this places on the caller.

func ContextWithVerifiedCaller added in v0.24.0

func ContextWithVerifiedCaller(ctx context.Context, c *callerctx.Caller) context.Context

ContextWithVerifiedCaller records c, the caller a verifier mapped from an access token it verified (pkg/oidcx's ResourceVerifier), on ctx, together with the equivalent Claims (ClaimsFromCaller), built from that same verified claim set, and binds the two:

  • CallerFromRequest starts from c, so the Kind the verifier classified (security.auth.caller_kind_rules over the raw claims), the delegation chain and every other field reach the handler as the verifier mapped them;
  • RBAC, object ownership, the CSRF session binding and GraphQL authorization, which read ClaimsFromContext, see the same subject, roles, scopes and attributes, so nothing ever re-parses the token.

The binding holds only while those Claims are the ones on the context: once a later ContextWithClaims installs others, VerifiedCallerFromContext reports no verified caller and CallerFromRequest builds the caller from the claims now installed, so the handler's caller and the claims RBAC reads always name the same identity.

It stores a copy of c (callerctx.Caller.Clone). Like ContextWithClaims, call it only with a caller built from a token whose signature, expiry, issuer and audience were verified.

func EvalComposite

func EvalComposite(r *http.Request, groups [][]Gate) (*http.Request, error)

EvalComposite evaluates a disjunctive-normal-form auth expression: the outer slice is OR-of-groups, each inner slice is an AND of gates. It tries groups left-to-right and returns the request from the FIRST group whose every gate passes (short-circuit). Each non-empty group starts from a deep clone of r (via r.Clone) so that header/trailer map mutations AND context-carried claims written by a partially-passing-then-failing group cannot leak into the next group or the handler. Returns the original request and a *CompositeError when no group fully passes (fail-closed: empty/nil groups also fail): it is ErrAuthFailed (errors.Is, and the same Error text), and it wraps the error every failing gate returned, in the order they ran, so the route can answer with what its Bearer leaf said (BearerCause).

One group authenticates one identity (SONNY-794). Every set of verified claims a group's gates install (ContextWithClaims) must name the same subject and attributes["tenant"] as the claims the group already holds: those an earlier gate of the group installed, or, before any, those on r when the group began. A gate that installs claims for another identity fails the group with ErrIdentityConflict; otherwise "jwt && cookie" would admit one principal's access token beside another principal's session. Two claim sets of which either has no subject fail it too, unless neither has one and they are equal in every field (one token verified twice, say, by a host middleware and then by the group's jwt leaf): nothing shows that two different subject-less tokens name one principal. Equal sets match only when both sides built their Claims the same way: both from VerifyJWTWithClaims (or VerifyJWTWithClaimSet), say, or both through ContextWithVerifiedCaller (ClaimsFromCaller). One subject-less token verified one way by the host and the other way by the leaf still conflicts: ClaimsFromCaller carries no Email or Exp. The mTLS principal a GateMTLS resolves is a workload identity, not claims, and is not compared. When the identities agree, the route reads the claims the group's LAST claim-installing gate installed, and the caller built from them (CallerFromRequest, VerifiedCallerFromContext), so with two such gates the ORDER of the group decides whose roles, scopes and caller kind authorize the route.

The generator therefore refuses, on every server, an AND-group that holds a jwt leaf beside a cookie or an api_key leaf (SONNY-794, the Task 25b re-review of fix rounds 2 and 3). A jwt leaf and a cookie leaf install two claim sets, so the order of the group would decide whose claims authorize the route; and without a resource server both verify with the one session verifier, so a single JWT sent as the session cookie AND as the Bearer token satisfies both leaves, and the group is no second factor. With the built-in API-key scheme a Bearer token and the API key's request signature both need the one Authorization header, so "jwt && api_key" could never pass. Every generated group installs at most one set of claims, and its result does not depend on the order of its leaves. On every server a cookie leaf (GateCookie, GateSessionCookie) verifies its session on its own copy of the request, so the session never reaches the Authorization header a later leaf or the handler reads (the Task 25b fix round 3).

Note: r.Clone shares the request Body (io.ReadCloser) across groups — auth gates read TLS state, headers, and cookies, not the body, so this is intentional and out of scope for this isolation guarantee.

func ExtractCookieToken

func ExtractCookieToken(r *http.Request, cookieName string) (string, error)

ExtractCookieToken returns the JWT carried in the named HttpOnly auth cookie.

func HasIdentityControl added in v0.24.0

func HasIdentityControl(s string) bool

HasIdentityControl reports whether s, a claim that names who the caller is (sub, tenant, namespace), contains a character that is invisible or reorders text rather than one that is displayed:

  • a control character: C0, DEL or C1 (U+0080 to U+009F);
  • a bidirectional formatting control (Unicode Bidi_Control: the Arabic letter mark, the left-to-right and right-to-left marks, and the embeddings, overrides and isolates);
  • any other format character (Unicode Cf): the zero-width space and joiners (U+200B to U+200D), the word joiner (U+2060), the byte order mark (U+FEFF), the soft hyphen (U+00AD) and the tag characters (U+E0000 block), among others;
  • a line or paragraph separator (Unicode Zl, Zp: U+2028, U+2029), which starts a new line in a log viewer that honors them.

Such an identity carries no legitimate meaning, and it can make one subject or tenant display in an audit record, a log viewer or a console as another ("admin\u202Egnp" reads as "adminpng", "ad\u200Bmin" as "admin"). Both verifiers refuse a token whose sub, tenant or namespace fails it: validateClaimStrings here, and pkg/oidcx's resource verifier.

func HumanSubject added in v0.23.0

func HumanSubject(ctx context.Context) string

HumanSubject returns ctx's installed caller's Subject ONLY when Kind is KindHuman; every other Kind (KindService, KindWorkload, KindDevice, KindUnknown) returns "".

Use this — never MustCaller(ctx).Subject — anywhere "the human identity a verified JWT asserted, or nothing" is the actual requirement, e.g. a WebAuthn ceremony's owner/user-handle binding (R11-3, GAP-0057). MustCaller(ctx).Subject deliberately falls back to a workload identity (the mTLS certificate principal) when there is no human subject — correct for general "who is this caller, for logging/authorization" purposes, but wrong here: a client certificate is not an assertion of who is registering a passkey. Reading MustCaller(ctx).Subject directly for a WebAuthn owner would let a `mtls && jwt` (or `cac && jwt`) registration route admitted with a subject-less JWT silently register the passkey under the CERTIFICATE's principal instead of failing closed with webauthnx.ErrEmptyOwner — the exact regression this function prevents.

func LegacyCallerKind added in v0.24.0

func LegacyCallerKind(c *Claims) callerctx.Kind

LegacyCallerKind is the caller classification CallerFromRequest applied before SONNY-794: any verified token with a sub is callerctx.KindHuman, else one with a client_id (or azp) is callerctx.KindService, else callerctx.KindUnknown. It reads a service token with a sub, including a ThreadID machine token, as human (SONNY-2684).

It exists only for the deprecated security.auth.legacy_human_subjects, the opt-in that keeps the old rule on plain jwt routes while a deployment writes caller_kind_rules, and goes with it. Classify with CallerKindFromClaims instead.

func MTLSPrincipalFromContext added in v0.18.3

func MTLSPrincipalFromContext(ctx context.Context) (principal, classification string, ok bool)

MTLSPrincipalFromContext returns the identity recorded by ContextWithMTLSPrincipal (or ContextWithMTLSWorkload: its Subject and Classification). ok is false -- and both strings are empty -- when the request was not admitted through an mTLS route with a CertPolicy (no principal_mapping / cac_piv configured) or a workload verifier, when the policy rejected the certificate, or when ctx is nil. A false ok never means "anonymous but present": routes without a CertPolicy get no fabricated identity, so consumers that need one must configure principal_mapping (or workload_auth on a resource server).

func MTLSWorkloadFromContext added in v0.24.0

func MTLSWorkloadFromContext(ctx context.Context) (callerctx.Workload, bool)

MTLSWorkloadFromContext returns the whole mTLS identity recorded on ctx as a callerctx.Workload: Subject and Classification as MTLSPrincipalFromContext reports them, plus the SPIFFEID and Issuer the workload verifier sets (empty for a CertPolicy principal). ok is as for MTLSPrincipalFromContext.

func MustCaller added in v0.23.0

func MustCaller(ctx context.Context) *callerctx.Caller

MustCaller is a thin re-export so generated code needs one import for the whole caller story. Returns the caller installed by ContextWithCaller / ContextWithOwnedCaller, or an unknown caller with no roles and no scopes — it never returns nil. The returned pointer is shared with every other reader of ctx; treat it as read-only (see callerctx.MustCaller's doc).

func NewTestVerifier

func NewTestVerifier() func(*http.Request) error

NewTestVerifier returns an Auth-style request verifier that accepts any request carrying a non-empty `Authorization: Bearer <token>` header. Empty Authorization headers, malformed headers, and missing tokens all return ErrAuthFailed.

The returned function is intended for unit-test ergonomics ONLY. It does not validate the bearer token; any non-empty value passes. Production code MUST NOT wire this verifier -- the apic-generated registration calls explicitly panic with ErrAuthVerifierRequired if the caller supplies nil for a route declaring auth: "jwt", which makes the "forgot to wire a real verifier" failure mode loud at boot.

Example use in a test:

api.RegisterGeneratedAPI(mux, srv, api.APIOptions{
    AuthJWT: securex.NewTestVerifier(),
    Auth:    securex.NewTestVerifier(),
})

The same function value satisfies both APIKey and JWT verifier slots because both contracts are `func(*http.Request) error`.

func ParseSPIFFEID added in v0.24.0

func ParseSPIFFEID(uri string) (trustDomain, path string, err error)

ParseSPIFFEID splits a SPIFFE ID into its trust domain and path. It is configx.ParseSPIFFEID, apic's one SPIFFE grammar (SPIFFE ID specification §2: scheme "spiffe", a lowercase trust domain, an optional path of non-empty segments of letters, digits, ".", "-" and "_"; no userinfo, port, query, fragment, percent-encoding or trailing "/"), so an ID the configuration accepts is one the verifier can match. Its errors wrap both ErrSPIFFEMalformed and configx.ErrInvalidSPIFFEID.

func RequestWithAuth

func RequestWithAuth(r *http.Request) *http.Request

RequestWithAuth applies the hardened default request auth policy.

func RequestWithAuthPolicy

func RequestWithAuthPolicy(r *http.Request, policy RequestAuthPolicy) *http.Request

RequestWithAuthPolicy clones r and fills auth headers from the configured transport sources, including websocket subprotocol auth markers when browser clients cannot set custom headers during the upgrade.

func RequireABAC

func RequireABAC(secret []byte, policies ...ABACPolicy) func(http.Handler) http.Handler

RequireABAC returns an http.Handler middleware that enforces ABAC policies. The JWT is verified using the secret, and all policies must evaluate to true. Fails with 401 Unauthorized for invalid tokens, and 403 Forbidden for policy failures.

func RequireABACWithOptions

func RequireABACWithOptions(secret []byte, opts JWTVerifyOptions, policies ...ABACPolicy) func(http.Handler) http.Handler

RequireABACWithOptions is the iss/aud-aware variant of RequireABAC (A-01). The supplied JWTVerifyOptions bind the in-handler HS256 fallback to an expected issuer/audience. Zero-value opts reproduce the legacy behavior.

func RequireOwnership

func RequireOwnership(secret []byte, extract OwnerExtractor, next http.Handler) http.Handler

RequireOwnership returns middleware enforcing object-level authorization (SEC-0027 / OWASP API1:2023 BOLA): the authenticated subject (JWT "sub") must equal the resource-owner id returned by extract. This is the building block the generated by-id handlers (e.g. GET/PATCH/DELETE /v1/users/{userId}) cannot supply themselves, because only the consumer knows which claim maps to which resource. Wire it per owner-scoped route.

Secure-by-default: a missing/invalid token is 401; a subject that does not match the resource owner is a generic 403 (ErrOwnershipDenied, logged server-side only). If extract reports no owner-scoped resource, the request passes through unchanged so non-owned routes are unaffected. An empty JWT subject can never match and is therefore always denied.

func RequireOwnershipWithOptions

func RequireOwnershipWithOptions(secret []byte, opts JWTVerifyOptions, extract OwnerExtractor, next http.Handler) http.Handler

RequireOwnershipWithOptions is the iss/aud-aware variant of RequireOwnership (A-01). The supplied JWTVerifyOptions bind the in-handler HS256 fallback to an expected issuer/audience. Zero-value opts reproduce the legacy behavior.

func RequireRole

func RequireRole(minimum Role, secret []byte, next http.Handler) http.Handler

RequireRole returns an http.Handler middleware that enforces a minimum role. The JWT is read from "Authorization: Bearer <token>" and verified with secret. Requests whose highest role is below minimum, or that hold no role the built-in vocabulary knows (RoleNone, SONNY-3221), receive 403 Forbidden. Requests with missing/invalid tokens receive 401 Unauthorized.

func RequireRoleH added in v0.19.2

func RequireRoleH(h *RoleHierarchy, minimum Role, secret []byte, opts JWTVerifyOptions, next http.Handler) http.Handler

RequireRoleH is RequireRoleWithOptions bound to an EXPLICIT role vocabulary. Prefer it over RequireRole/RequireRoleWithOptions, which can only resolve against the built-in admin > manager > user vocabulary: a server whose security.roles declares platform_admin > assessor > viewer needs its own hierarchy, and before ENG-4634 the only way to install one was SetRoleHierarchy, which mutated a process-wide graph shared with every other server in the binary.

A nil hierarchy falls back to the built-in default, so RequireRoleH(nil, ...) is exactly RequireRoleWithOptions.

func RequireRoleWithOptions

func RequireRoleWithOptions(minimum Role, secret []byte, opts JWTVerifyOptions, next http.Handler) http.Handler

RequireRoleWithOptions is the iss/aud-aware variant of RequireRole (A-01). The supplied JWTVerifyOptions are honoured on the in-handler HS256 fallback, binding the token to an expected issuer/audience so a validly-signed token minted for a different service is rejected (401) rather than accepted. When opts is the zero value this behaves exactly like the legacy RequireRole; set Issuer/Audience (and the matching Require* flags) to enforce binding.

func SetAuditSink added in v0.18.3

func SetAuditSink(fn func(event string, fields map[string]any))

SetAuditSink installs fn as the destination for WriteUnauthorized and WriteForbidden's audit events. Passing nil restores the default sink, which logs via slog.Default().Warn(event, "fields", fields). pkg/securex imports pkg/obsx only to read request-scoped ids (CallerFromRequest), and deliberately leaves the audit destination to the caller rather than hard-wiring obsx.LogAudit, so a host without obsx's logger still gets the events. Callers that want auth decisions routed through obsx's redacted fanout call securex.SetAuditSink(obsx.LogAudit) themselves (generated servers do this in server_lib.go.tmpl / cli/serve).

func SetRateHeaders

func SetRateHeaders(w http.ResponseWriter, limit, remaining int, reset time.Time)

SetRateHeaders sets basic rate-limit headers on the response.

func SignAPIKeyRequest

func SignAPIKeyRequest(r *http.Request, secret []byte, now time.Time) error

SignAPIKeyRequest signs r using the same canonical request rules enforced by APIKeyAuth. It sets Authorization, X-Nonce, and X-Timestamp on the request.

func SignAPIKeyRequestWithValues

func SignAPIKeyRequestWithValues(r *http.Request, secret []byte, nonce, ts string) error

SignAPIKeyRequestWithValues signs r with explicit nonce and timestamp values. This is useful for deterministic tests and for flows that need to precompute signed WebSocket auth material.

func SignWebhookRequest

func SignWebhookRequest(r *http.Request, secret []byte, alg WebhookAlg, now time.Time) error

SignWebhookRequest stamps r with X-Webhook-Id, X-Timestamp, and the HMAC signature header so the request passes VerifyWebhook against the same secret. Use this from outbound delivery code and from tests; the receiver does NOT depend on this helper.

The id is a 16-byte random nonce (base64-url, no padding) — the same shape APIKeyAuth uses for X-Nonce so consumers see a uniform id format across both auth modes. now is supplied so tests can pin a deterministic timestamp; production callers pass time.Now().

func StartNonceGC

func StartNonceGC(ctx context.Context, seen *sync.Map, interval time.Duration) chan struct{}

StartNonceGC starts a GC loop to remove expired nonces stored by APIKeyAuth. N-15: context-first (ctx is the first parameter, matching the repo's documented context-first convention) and clamps a non-positive interval to defaultNonceGCInterval instead of letting time.NewTicker panic. gen.SafeLoop recovers a panic and restarts the loop body, but interval is a closed-over value that never changes across restarts, so an invalid interval previously spun forever in a panic/backoff/restart cycle rather than ever making progress. The returned channel keeps its existing contract (closing it stops the loop); ctx cancellation stops it too, whichever comes first.

func StartNonceGCLegacy deprecated added in v0.17.0

func StartNonceGCLegacy(seen *sync.Map, interval time.Duration) chan struct{}

StartNonceGCLegacy is a back-compat shim for callers written against the pre-N-15 two-argument StartNonceGC(seen, interval) signature (Q-3).

Deprecated: use StartNonceGC(ctx, seen, interval). N-15 added a leading ctx parameter to StartNonceGC so the GC loop honors caller cancellation (the repo's documented context-first convention); that changed StartNonceGC's signature without a compatibility path or a BREAKING CHANGE footer, and it shipped uncompilable example code in docs/WEBHOOK_MODE.md as a result. StartNonceGCLegacy runs the loop with context.Background() -- identical to StartNonceGC's pre-N-15 behavior, which never observed cancellation either -- so old call sites keep compiling and behaving exactly as before while new code uses StartNonceGC directly for ctx-aware shutdown.

func VerifiedCallerFromContext added in v0.24.0

func VerifiedCallerFromContext(ctx context.Context) (*callerctx.Caller, bool)

VerifiedCallerFromContext returns the caller ContextWithVerifiedCaller recorded on ctx, or (nil, false), including when a later ContextWithClaims replaced the Claims recorded with it: the caller is reported only together with the claims built from its own token. The pointer is shared by every reader of ctx: treat it as read-only.

func VerifyJWT

func VerifyJWT(token string, secret []byte) error

VerifyJWT verifies a compact JWS (HS256) with the provided secret. Returns ErrJWTUnsupported if algorithm is not HS256 or token malformed.

SECURE DEFAULT (SEC-0028): VerifyJWT REQUIRES the "exp" claim. A token that omits "exp" never expires, which turns a single leaked bearer token into a permanent authorization-server compromise primitive. RFC 7519 §4.1.4 marks "exp" OPTIONAL, but a resource server MUST NOT accept a non-expiring access token. Callers that truly need the legacy accept-no-exp behavior must opt in explicitly via VerifyJWTAllowNoExpiry (or VerifyJWTWithOptions with RequireExpiry:false) and own that risk.

func VerifyJWTAllowNoExpiry

func VerifyJWTAllowNoExpiry(token string, secret []byte) error

VerifyJWTAllowNoExpiry is the explicit, clearly-named opt-out from the VerifyJWT secure default (SEC-0028). It verifies the HS256 signature and any present "exp"/iss/aud claims, but — unlike VerifyJWT — it ACCEPTS a token that omits "exp" entirely (a never-expiring token).

This exists only for narrow legacy/back-compat call sites that knowingly issue or accept non-expiring tokens. Prefer VerifyJWT (or VerifyJWTWithOptions with the appropriate Require* fields) for anything exposed to untrusted input. Using this on an internet-facing resource server is an authorization weakness.

func VerifyJWTWithJWKS

func VerifyJWTWithJWKS(_ context.Context, token string, keys jwk.Set, opts JWKSVerifyOptions) (jwt.Token, error)

VerifyJWTWithJWKS parses and verifies a compact JWS token using the supplied jwk.Set. Returns the parsed jwt.Token on success, or ErrAuthFailed / ErrJWTUnsupported / ErrJWKSEmpty on failure. A token over MaxJWTBytes is ErrJWTUnsupported before anything is decoded, and a correctly signed but expired token is ErrAuthFailed wrapping ErrJWTExpired.

The asymmetric-only contract is enforced before signature verification: the JWS header `alg` is parsed and compared against the allowlist; any `none` or HMAC alg returns ErrJWTUnsupported without touching keys or running the underlying jwt.Parse signature path.

func VerifyJWTWithOptions

func VerifyJWTWithOptions(token string, secret []byte, opts JWTVerifyOptions) error

VerifyJWTWithOptions verifies a compact JWS (HS256) with the provided secret and enforces optional issuer/audience checks when configured.

func VerifyMTLS

func VerifyMTLS(r *http.Request, policy MTLSPolicy) error

VerifyMTLS enforces a per-route mTLS policy against an HTTP request whose connection terminated on a TLS listener. Call after the listener has completed the TLS handshake but before any handler logic runs.

Checks, in order:

  1. r and r.TLS are non-nil. Defends against an accidentally plaintext reverse-proxy hop in front of the server stripping mTLS state.
  2. If policy.Required, or policy.Workload is set, at least one peer certificate is present.
  3. The peer cert chain has been verified by the listener (r.TLS.VerifiedChains is non-empty).
  4. If policy.EKUValidation, the leaf certificate carries the clientAuth (or ExtKeyUsageAny) Extended Key Usage.
  5. If policy.Runtime.CRL and/or policy.Runtime.OCSP is configured (L-51), the leaf is checked against the issuer's CRL/OCSP revocation state. A zero-value Runtime is a no-op here.
  6. If policy.Runtime.CertPolicy is configured (L-51), the leaf is verified against the CAC/PIV certificate-classification policy.
  7. If policy.Workload is set (SONNY-794), the certificate must be a workload it accepts.

SupportedIssuers is intentionally NOT enforced. Callers that need issuer-label validation should layer their own verifier on top after VerifyMTLS returns nil; the apic-emitted handler invokes opts.AuthMTLS for exactly that purpose.

VerifyMTLS is the error-only form: it enforces exactly what VerifyMTLSRequest enforces but cannot hand back the request enriched with the identity step 6 resolved. Callers that run a business handler after the gate should use VerifyMTLSRequest so the principal reaches it (L-55).

func VerifyMTLSRequest added in v0.18.3

func VerifyMTLSRequest(r *http.Request, policy MTLSPolicy) (*http.Request, error)

VerifyMTLSRequest is VerifyMTLS that also returns the request to continue with. When policy.Runtime.CertPolicy accepts the leaf, the returned request's context carries the resolved principal and classification (MTLSPrincipalFromContext) and -- when the verifier implements CertPolicyContextVerifier, as the generated server's cacpiv adapter does -- the full identity object (mtlsx.From). Otherwise r is returned as-is: a route with no CertPolicy gets NO fabricated identity, and on any error the returned request carries nothing new (the Gate contract's "stash only on success" rule), so a failed AND-group leaves no principal residue.

func VerifyWebhook

func VerifyWebhook(r *http.Request, secret []byte, pol WebhookPolicy) error

VerifyWebhook checks r against pol using the supplied secret. Returns nil on success. Returns ErrBodyTooLarge (detectable via errors.Is) when the request body exceeds pol.MaxBodyBytes so callers can map it to HTTP 413 — mirrors APIKeyAuthWithLimit. Returns ErrWebhookFailed for every other rejection: empty secret, missing/malformed headers, non-allowlisted alg, expired timestamp, bad signature, replay.

The body is rewrapped on success so the user handler can re-read it; on failure the body may be partially consumed and the caller should treat r as untrusted.

secret is supplied per-request (not stored on pol) so the generator can resolve it from APIOptions.AuthWebhook keyed on pol.Name. Zero (nil/empty) secret causes verification to fail regardless of any other input.

func WebhookReplayStorePtr

func WebhookReplayStorePtr() *sync.Map

WebhookReplayStorePtr returns a pointer to the package-shared webhook replay store. Pass this to StartNonceGC at server start so expired entries (entries whose value time.Time is in the past) are reaped on the same cadence as the API-key nonce store. Task 10 of the webhook-receiver plan wires this from server.go.tmpl.

Returning a *sync.Map (not a copy) is intentional: StartNonceGC mutates the map in place to delete expired keys.

func WriteBearerUnauthorized added in v0.24.0

func WriteBearerUnauthorized(w http.ResponseWriter, err error)

WriteBearerUnauthorized answers, with 401, a request that the plain jwt verifier (HS256, security.auth.jwt) refused on a route that accepts a Bearer token, so the 401 carries the challenge RFC 9110 §15.5.2 requires: MinimalBearerChallenge when the request presented no Bearer token (ErrNoBearerToken: RFC 6750 §3.1 gives that no error code), and InvalidTokenChallenge when it presented one that failed. The status, body and audit event are WriteUnauthorized's; err reaches the audit sink only.

ErrIdentityConflict (a composite AND-group whose credentials named two principals) is the exception: no token failed, so it is WriteForbidden's 403 with no challenge (a stale WWW-Authenticate on w is removed), which tells the client not to repeat the request with the same credentials and not to discard a valid token either.

A resource-server route answers with pkg/oidcx's ResourceVerifier instead, whose challenges also name the RFC 9728 metadata document. API-key, HMAC and cookie-only routes keep WriteUnauthorized: a Bearer challenge would name the wrong scheme.

func WriteChallenge added in v0.24.0

func WriteChallenge(w http.ResponseWriter, internalErr error, status int, challenge string)

WriteChallenge writes challenge with the writer status calls for, so the status a challenge was built for and the writer that sends it cannot disagree: http.StatusUnauthorized goes to WriteUnauthorizedChallenge and http.StatusForbidden to WriteForbiddenChallenge. Pass pkg/oidcx's Challenge.Status and Challenge.Header together.

Any other status fails closed. securex has no challenge writer for it (not even for the 400 that Challenge.Status gives invalid_request), and the challenge was built for that status, so it is not sent: the response is WriteUnauthorized's plain 401 with MinimalBearerChallenge, and securex.auth_challenge_status_refused is audited with the status but without the challenge text, before WriteUnauthorized's own event.

func WriteForbidden

func WriteForbidden(w http.ResponseWriter, internalErr error)

WriteForbidden writes the package-standard 403 response: Content-Type application/json, status 403, and the spec ApiError envelope `{"code","message","requestId"}` with a generic, framework-neutral code (GenericForbiddenCode) and message. The supplied internalErr (typically ErrInsufficientRole, or a wrapped scope / attribute reason) is logged at WARN level via log/slog so operators can still triage the underlying cause — it is intentionally NOT echoed into the response body.

Mirrors WriteUnauthorized in shape so the two helpers stay behaviour-symmetric for downstream regression tests that diff the 401 and 403 surfaces side-by-side.

All package-internal call sites that previously emitted `http.Error(w, ErrInsufficientRole.Error(), 403)` or `writeJSONError(w, securex.ErrInsufficientRole.Error(), 403)` MUST route through this helper to guarantee the A-005 closure across the generated server set.

func WriteForbiddenChallenge added in v0.24.0

func WriteForbiddenChallenge(w http.ResponseWriter, internalErr error, challenge string)

WriteForbiddenChallenge is WriteUnauthorizedChallenge for WriteForbidden's 403, which RFC 6750 §3.1 prescribes for insufficient_scope: the token is valid, the grant is not. The A-005 guarantee holds the same way. RFC 9110 requires no challenge on a 403, so an empty or refused challenge sends none: it sets no header and removes a stale WWW-Authenticate value already on w, which would otherwise reach the client as if it described this 403.

func WriteServiceUnavailable added in v0.24.0

func WriteServiceUnavailable(w http.ResponseWriter, internalErr error, retryAfter time.Duration)

WriteServiceUnavailable writes a 503 with Retry-After (whole seconds, at least one) and the generic ApiError envelope, for a request whose credentials could not be checked for a reason that is not theirs: a resource verifier whose authorization server's key set is unavailable, or whose lifetime has ended. It is not an authentication failure, so it carries no WWW-Authenticate challenge: a client should retry, not get a new token. internalErr reaches the audit sink as securex.auth_unavailable, never the body.

func WriteUnauthorized

func WriteUnauthorized(w http.ResponseWriter, internalErr error)

WriteUnauthorized writes the package-standard 401 response: Content-Type application/json, status 401, and the spec ApiError envelope `{"code","message","requestId"}` with a generic, framework-neutral code (GenericUnauthorizedCode) and message. The supplied internalErr (typically one of the package-level Err* sentinels) is logged at WARN level via log/slog so operators can still triage the underlying cause from server logs — it is intentionally NOT echoed into the response body.

All package-internal call sites that previously emitted `http.Error(w, ErrAuthFailed.Error(), 401)` or `writeJSONError(w, securex.ErrAuthFailed.Error(), 401)` MUST route through this helper to guarantee the F-CR-004 closure across the generated server set.

func WriteUnauthorizedChallenge added in v0.24.0

func WriteUnauthorizedChallenge(w http.ResponseWriter, internalErr error, challenge string)

WriteUnauthorizedChallenge writes WriteUnauthorized's 401 with challenge as its WWW-Authenticate header (RFC 9110 §11.6.1): an RFC 6750 Bearer challenge, such as pkg/oidcx's Challenge.Header renders, telling the client what failed and where to get a suitable token (RFC 9728 §5.1). The status, Content-Type, generic body and audit event are exactly WriteUnauthorized's, so the F-CR-004 guarantee holds: the header is standardized text with no framework name, cause or claim in it, and internalErr reaches the audit sink only. An empty challenge, or one that is refused because it is not a single line of printable ASCII or is only spaces (see setChallenge), is replaced by MinimalBearerChallenge, so the 401 always carries the challenge RFC 9110 §15.5.2 requires. Either way the header is set, not added, so a stale WWW-Authenticate value already on w is overwritten.

Use it only on routes that accept a Bearer token. WriteUnauthorized, which sets no challenge, stays the writer for API-key, HMAC and cookie routes, where a Bearer challenge would name the wrong scheme.

Types

type ABACPolicy

type ABACPolicy interface {
	// Evaluate returns true if the claims satisfy the policy.
	Evaluate(c *Claims) bool
}

ABACPolicy defines an attribute evaluation policy.

func HasAttribute

func HasAttribute(key string) ABACPolicy

HasAttribute returns a policy that evaluates to true if the attribute key exists.

func MatchAttribute

func MatchAttribute(key, value string) ABACPolicy

MatchAttribute returns a policy that evaluates to true if the attribute exactly matches the given value.

type ABACPolicyFunc

type ABACPolicyFunc func(c *Claims) bool

ABACPolicyFunc is a function type that implements ABACPolicy.

func (ABACPolicyFunc) Evaluate

func (f ABACPolicyFunc) Evaluate(c *Claims) bool

Evaluate calls the underlying function.

type AuthSource

type AuthSource int

AuthSource identifies how a request presented its credential. The CSRF middleware enforces only AuthSourceCookie (ambient browser credential); AuthSourceHeader (Bearer) is exempt because a cross-origin page cannot set the Authorization header.

const (
	// AuthSourceNone means no credential was found on the request.
	AuthSourceNone AuthSource = iota
	// AuthSourceHeader means the credential was a well-formed
	// "Authorization: Bearer <token>" header.
	AuthSourceHeader // Authorization: Bearer …
	// AuthSourceCookie means the credential was the named HttpOnly auth
	// cookie — an ambient browser credential, which is why the CSRF
	// middleware enforces only this source.
	AuthSourceCookie // HttpOnly auth cookie
	// AuthSourceQuery means the credential was carried in the
	// ?access_token= query parameter.
	AuthSourceQuery // ?access_token=…
)

func AuthSourceConsumed added in v0.18.3

func AuthSourceConsumed(r *http.Request) AuthSource

AuthSourceConsumed reports the credential transport a request was actually admitted on, for CSRF classification AFTER an auth ladder has run. It is the post-authentication counterpart of AuthSourceForRequest: when the winning auth path lifted and verified the session cookie it stamped AuthSourceCookie on the context (ContextWithAuthSource) and that marker is authoritative; otherwise the ambient cookie was NOT the credential -- an api_key, mTLS, webhook, or bearer path admitted the request without ever reading it -- so its mere presence must not classify the request as cookie-authenticated. Before this helper the generated handlers fell back to AuthSourceForRequest for that case, which reports Cookie whenever a session cookie is present and no well-formed bearer header is, so an api_key/mTLS/webhook client carrying a stale browser cookie was refused with 403 on every state-changing call (deny-side only, but wrong). The bearer-header and ?access_token= classifications are kept so a state-changing request admitted on a query token is still refused.

func AuthSourceForRequest

func AuthSourceForRequest(r *http.Request, cookieName string) AuthSource

AuthSourceForRequest reports the credential transport, mirroring the extraction priority: Authorization header > auth cookie > ?access_token=.

SEC-0060: this used to treat ANY non-empty Authorization header as AuthSourceHeader, including a malformed value like "Authorization: junk" that no verifier would ever accept. On a cookie-auth route, a forged header (attacker-controllable cross-site — unlike the victim's ambient session cookie) made this report Header while the cookie-lift auth branch still authenticated the request from the real cookie, so the CSRF middleware (which enforces only the Cookie source) never ran. Only a well-formed "Bearer <token>" header now counts as the header source; a malformed one falls through to the cookie check.

func AuthSourceFromContext added in v0.18.3

func AuthSourceFromContext(ctx context.Context) (AuthSource, bool)

AuthSourceFromContext returns the AuthSource stashed by ContextWithAuthSource, or (AuthSourceNone, false) if none was stashed.

func (AuthSource) String added in v0.23.0

func (s AuthSource) String() string

String renders a stable, human-readable label for the credential transport (which of Authorization header / cookie / query carried it) — not the route's auth SCHEME (jwt/mtls/api_key/webhook/composite/public). securex.CallerFromRequest uses it as a fallback for callerctx.Caller .AuthMode when no route-scheme override is available; SEC-0060's AuthSourceForRequest/AuthSourceConsumed CSRF classification is the other consumer.

type Bucket

type Bucket = ratebucket.Bucket

Bucket provides a simple token bucket limiter for per-route/tool scoping. Bucket aliases the canonical token bucket. securex previously carried a byte-identical private copy of this algorithm (QG-046/059/PERF-0044); Allow/Snapshot/AllowAndSnapshot now come from the shared ratebucket type.

func NewBucket

func NewBucket(rate, burst float64) *Bucket

NewBucket creates a new token bucket with the provided rate and burst.

type BucketSnapshot

type BucketSnapshot = ratebucket.Snap

BucketSnapshot is the value form of (limit, remaining, resetTime) returned by AllowAndSnapshot. Aliases ratebucket.Snap (fields Limit, Remaining, Reset).

type CertPolicyContextVerifier added in v0.18.3

type CertPolicyContextVerifier interface {
	CertPolicyVerifier
	VerifyContext(ctx context.Context, leaf *x509.Certificate) (enriched context.Context, principal, classification string, err error)
}

CertPolicyContextVerifier is an optional extension of CertPolicyVerifier. A verifier that can also produce a richer identity object (the pkg/securex/cacpiv adapter attaches the full *mtlsx.Principal via mtlsx.ContextWith) implements VerifyContext; VerifyMTLSRequest prefers it over Verify when present so the enriched context flows to the handler.

Contract: on a non-nil error the returned context MUST be ctx unchanged (nothing stashed on the failure path -- the same rule Gate imposes on claims), and the string results carry the same meaning as CertPolicyVerifier.Verify's. This package never imports mtlsx (mtlsx imports securex), which is why the rich principal travels through the verifier-owned context rather than a type named here.

type CertPolicyVerifier added in v0.17.0

type CertPolicyVerifier interface {
	Verify(leaf *x509.Certificate) (principal, classification string, err error)
}

CertPolicyVerifier enforces certificate-classification policy (e.g. DOD CAC / federal PIV require_person / required_policy_oids / reject_unknown_classification) against a verified leaf certificate and resolves the configured mtls.principal_mapping to an identity string. A non-nil error fails the request closed. Concrete implementations live in pkg/securex/cacpiv (Adapter.Verify).

type CertPolicyVerifierFunc added in v0.17.0

type CertPolicyVerifierFunc func(leaf *x509.Certificate) (principal, classification string, err error)

CertPolicyVerifierFunc adapts a plain function to CertPolicyVerifier, mirroring the standard library's http.HandlerFunc idiom. The generated server uses this to wrap a boot-constructed cacpiv.Adapter (whose Verify method takes an extra mapping argument baked in via closure) without requiring pkg/securex to import pkg/securex/cacpiv.

func (CertPolicyVerifierFunc) Verify added in v0.17.0

Verify implements CertPolicyVerifier by calling f.

type Claims

type Claims struct {
	Subject    string            `json:"sub"`
	Roles      []string          `json:"roles"`
	Scopes     []string          `json:"scopes"`
	Attributes map[string]string `json:"attributes,omitempty"`

	// Email is the standard `email` claim, surfaced for handler convenience
	// (F4). Empty when the token omits it.
	Email string `json:"email,omitempty"`
	// Exp is the standard `exp` (expiry) claim as a Unix timestamp (seconds).
	// Zero when absent. Signature/expiry are enforced by VerifyJWT, not here.
	Exp int64 `json:"exp,omitempty"`

	// AMR is the RFC 8176 Authentication Methods References list, e.g.
	// ["webauthn"], ["pwd","otp"], ["mfa"]. Populated by ParseClaims
	// from the `amr` JSON array claim when present; consumed by
	// webauthnx.RequireAMR (GENWA-R8) to gate step-up routes. An empty
	// or absent claim leaves the slice nil — RequireAMR treats nil as
	// "no method asserted" and 403s any required-AMR gate.
	AMR []string `json:"amr,omitempty"`

	// Issuer is the standard `iss` claim. Trustworthy as soon as the token's
	// signature verifies (that's what makes it unforgeable) — independent of
	// whether the verifier's JWTVerifyOptions.Issuer/RequireIssuer matched or
	// required it against an expected value; those are a separate, optional
	// value-comparison check, not what makes this field safe to read.
	Issuer string `json:"iss,omitempty"`
	// ClientID is the OAuth2 client that obtained the token: the standard
	// `client_id` claim, falling back to the OIDC `azp` (authorized-party)
	// claim (see AZP) when `client_id` is absent — both name the same thing
	// under different conventions (OAuth2 access tokens vs. OIDC ID tokens).
	// ParseClaims and VerifyJWTWithClaims both apply the fallback.
	ClientID string `json:"client_id,omitempty"`
	// AZP is the raw OIDC `azp` claim, decoded independently of the
	// ClientID fallback above. Read ClientID unless you specifically need
	// to tell the two claims apart.
	AZP string `json:"azp,omitempty"`
	// ACR is the standard `acr` (Authentication Context Class Reference)
	// claim.
	ACR string `json:"acr,omitempty"`
}

Claims holds the JWT payload fields relevant to RBAC and scope enforcement. Call ParseClaims only after the token signature has been verified.

func ClaimsFrom

func ClaimsFrom(ctx context.Context) (*Claims, bool)

ClaimsFrom is the public, ergonomically-named accessor for the verified claims a JWT-verifying gate stashed in ctx (F4). Thin alias of ClaimsFromContext: returns (claims, true) when present, else (nil, false).

func ClaimsFromCaller added in v0.24.0

func ClaimsFromCaller(c *callerctx.Caller) *Claims

ClaimsFromCaller returns the Claims equivalent of a verified caller, for the checks that read ClaimsFromContext: Subject, Roles, Scopes, AMR, Issuer, ClientID and ACR, and Attributes with "tenant" and "namespace" set to the caller's Tenant and Namespace, so every reader sees one value for each. Everything is copied. Email and Exp are left zero: a caller carries neither. Nil for a nil caller.

func ClaimsFromContext

func ClaimsFromContext(ctx context.Context) (*Claims, bool)

ClaimsFromContext returns the Claims pointer stashed by ContextWithClaims, or (nil, false) if no claims have been attached. The returned pointer is the same value passed to ContextWithClaims (not a copy); callers MUST treat it as read-only or risk torn reads across goroutines that share the request context. (S-NEW-3.)

func ParseClaims

func ParseClaims(token string) (*Claims, error)

ParseClaims decodes the payload of a compact JWS token and returns the Claims. It does NOT verify the token signature; callers must call VerifyJWT first. It DOES refuse (ErrClaimControlChar, wrapped in ErrAuthFailed) a role or scope string carrying a control character, so every verifier that types its claims through here shares that invariant (SEC-0081, #381).

func VerifyJWTWithClaimSet added in v0.24.0

func VerifyJWTWithClaimSet(token string, secret []byte, opts JWTVerifyOptions) (*Claims, map[string]any, error)

VerifyJWTWithClaimSet verifies a compact JWS (HS256) exactly as VerifyJWTWithClaims does, and on success also returns the verified payload's top-level claims as decoded JSON (string, float64, bool, []any, map[string]any), from the same decoding the exp/nbf/iat/iss/aud checks read. The typed *Claims drop claims apic has no field for and fold azp into ClientID; a caller-kind rule (configx.ClassifyCaller) must see every claim the token carries, literally (an issuer-reserved claim such as ThreadID's _tid_subject_kind, and client_id never aliased from azp), so the generated plain jwt verifier classifies its caller from this map. The map is the caller's to keep; nothing else holds it. It never re-parses the token: both results come from the one payload decode.

func VerifyJWTWithClaims added in v0.17.0

func VerifyJWTWithClaims(token string, secret []byte, opts JWTVerifyOptions) (*Claims, error)

VerifyJWTWithClaims verifies a compact JWS (HS256) exactly as VerifyJWTWithOptions does, and on success returns the *Claims decoded from the SAME payload segment the verifier already base64-decoded.

NP-05: the pre-existing call pattern was VerifyJWTWithOptions (decodes the payload into map[string]any to run the exp/nbf/iat/iss/aud checks) immediately followed by ParseClaims(token) (re-splits the compact JWS, re-base64-decodes the SAME payload segment, and re-unmarshals it into Claims) on every authenticated request. VerifyJWTWithClaims shares the split + signature verification + base64 payload decode with VerifyJWTWithOptions via verifyJWTPayload, and pays only ONE extra json.Unmarshal (into the typed Claims struct, which is not interchangeable with the map[string]any the claim checks need — Claims has no iss/aud/nbf fields) instead of re-doing the split and base64 decode as well.

Every check below is byte-for-byte the same as VerifyJWTWithOptions (verifyJWTPayload IS that check body) — this is a security-sensitive path and callers must be able to rely on identical accept/reject behavior between the two entry points.

func (*Claims) HasAMR

func (c *Claims) HasAMR(method string) bool

HasAMR reports whether c.AMR contains the given method reference. RFC 8176 §1 defines the claim as a JSON array of case-sensitive strings; matching is exact (no case-folding) to stay faithful to the registry (https://www.iana.org/assignments/amr-values).

func (*Claims) HasScope

func (c *Claims) HasScope(scope string) bool

HasScope returns true if the claims list contains the exact scope string.

func (*Claims) HighestRole

func (c *Claims) HighestRole() Role

HighestRole returns the highest Role found in the claims' Roles list, resolved against the BUILT-IN admin > manager > user vocabulary. Returns RoleNone, which HasRole never admits, if the list is empty or contains no recognised role names (SONNY-3221; RoleUser before).

A server configured with a custom role vocabulary MUST use (*RoleHierarchy).Highest against its own hierarchy instead: this method cannot see one, and every name outside the built-in three is ignored here (ENG-4634). apic emits the hierarchy-aware form.

type CompositeError added in v0.24.0

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

CompositeError is what EvalComposite returns when no group of a composite route's auth expression passes. It is ErrAuthFailed (errors.Is, and the same Error text, so audit records read as before), and it wraps every error a gate returned (errors.Is and errors.As see them), so a route can answer with what its Bearer leaf said (BearerCause), including an unavailable key set.

func (*CompositeError) Error added in v0.24.0

func (e *CompositeError) Error() string

Error implements error with ErrAuthFailed's text: the causes stay out of it, so a composite denial reads the same whatever the leaves said.

func (*CompositeError) Is added in v0.24.0

func (e *CompositeError) Is(target error) bool

Is reports whether target is ErrAuthFailed.

func (*CompositeError) Unwrap added in v0.24.0

func (e *CompositeError) Unwrap() []error

Unwrap returns the errors the gates returned, in the order they ran.

type Gate

type Gate func(r *http.Request) (*http.Request, error)

Gate is one composable authentication check used by EvalComposite. It returns the (possibly claim-augmented) request and nil on success, or the request and a non-nil error on failure. A Gate MUST stash verified claims (via ContextWithClaims on the returned request) ONLY when it succeeds — never on the failure path — so that a failed AND-group leaves no claim residue. A Gate's verifier MUST verify the token signature and expiry BEFORE stashing claims via ContextWithClaims; stashing claims for an unverified token is a security defect because downstream RBAC (RequireRole, authzFromClaims) trusts these claims without re-checking the signature.

func GateAPIKey

func GateAPIKey(verifyAPIKey func(*http.Request) error) Gate

GateAPIKey runs the API-key verifier.

func GateCookie

func GateCookie(cookieName string, verifyJWT func(*http.Request) error) Gate

GateCookie is a cookie leaf on a server without a resource server: it verifies the session JWT from the named cookie with the shared JWT verifier and, when the request carries no such cookie, falls back to the Authorization header (a session JWT sent as a Bearer token).

The session is lifted into the Authorization header of the gate's OWN copy of the request, which only the verifier sees. On success the gate returns the request it was given, headers untouched, with the context the verifier built on that copy (the session's claims) and AuthSourceCookie stamped (ContextWithAuthSource): the session never reaches the Authorization header the next gate of an AND-group, or the handler, reads (SONNY-794, the Task 25b fix round 3). Before, the gate returned its copy with the session in the header: in "cookie && jwt" the jwt leaf verified the session again, so the cookie alone passed whatever Bearer token the request carried or lacked; in "cookie && api_key" the API key's request signature was overwritten, so no request passed; and "api_key && cookie" passed, but the handler found the session in place of the signature. Handlers and later gates read the session's identity from the context (ClaimsFromContext, CallerFromRequest), never from the header. The gate keeps the verifier's context only: a header the verifier writes on its copy is discarded, so a host verifier must hand identity on through the context. On failure it returns the request it was given and the verifier's error.

SEC-0060/0061: AuthSourceCookie is stamped ONLY on that success path (mirroring the Gate contract's "stash only on success" rule for claims) and ONLY when the cookie was actually present and consumed, never when the gate fell through to the Authorization header with no cookie in play. This lets CSRF classification (in the generated handler) read back the credential THIS gate actually consumed instead of assuming every route whose auth expression carries a "cookie" leaf always consumed the cookie: a composite route like "api_key || cookie" may be admitted by a different OR-group without this gate ever running.

The header fallback means one Bearer token can satisfy this gate AND a jwt leaf (before the Task 25b fix round 3 a "cookie && jwt" group, in either order, accepted a Bearer token alone that way), and even without it one JWT sent as the session cookie and as the Bearer token satisfies both, since both leaves verify with the same session verifier. The generator therefore refuses an AND-group holding a cookie leaf and a jwt leaf, on every server (EvalComposite).

func GateDeny

func GateDeny() Gate

GateDeny always fails closed; emitted for an unrecognized DNF leaf.

func GateJWT

func GateJWT(verifyJWT func(*http.Request) error) Gate

GateJWT runs the JWT (Bearer) verifier, which stashes verified claims on success. Its error is marked as the Bearer leaf's, so a composite route that fails answers with what this leaf said (BearerCause).

func GateMTLS

func GateMTLS(hook func(*http.Request, MTLSPolicy) error, policy MTLSPolicy) Gate

GateMTLS verifies client-cert presence/EKU via VerifyMTLSRequest, then runs the optional issuer/CAC-PIV policy hook. A nil hook still requires a valid cert (fail-closed). cac/piv tokens map to GateMTLS; their issuer policy is enforced by the wired hook plus the route's MTLSPolicy.

L-55: on success the returned request is the one VerifyMTLSRequest enriched with the resolved principal/classification (see MTLSPrincipalFromContext / mtlsx.From), and the hook observes that same enriched request. On any failure -- VerifyMTLSRequest or the hook -- the gate hands back the request it was given, so no identity leaks out of a failed AND-group.

func GateSessionCookie added in v0.24.0

func GateSessionCookie(cookieName string, verifyJWT func(*http.Request) error) Gate

GateSessionCookie is GateCookie reading the session JWT from the named cookie ONLY: a request without the cookie fails with ErrNoSessionCookie and the verifier never runs, since there is no fallback to the Authorization header. The generator emits it for every cookie leaf on a server whose Bearer tokens are OAuth access tokens (security.resource_server, SONNY-794), where the header carries an access token for the resource verifier, never a session, so a session JWT presented as a Bearer token is refused rather than admitted by the cookie leaf. Beside a jwt leaf the gate still needs the cookie, so such a group needs a session cookie AND a Bearer token; but on a server without a resource server one JWT sent as both still satisfies it, so the generator refuses a cookie leaf beside a jwt leaf in one AND-group, on every server (EvalComposite).

Like GateCookie it verifies the session on its own copy of the request and on success returns the request it was given, headers untouched, with the context the verifier built on the copy (the session's claims) and AuthSourceCookie stamped: the session never reaches the Authorization header of the request the next gate of an AND-group, or the handler, sees, so a jwt leaf after it still reads the caller's Bearer token and an api_key leaf still reads the request signature (SONNY-794, Task 25b re-review). A header the verifier writes on its copy is discarded. On failure it returns the request it was given and the verifier's error.

type JWKSVerifyOptions

type JWKSVerifyOptions struct {
	// Issuer, when non-empty, is matched against the token's `iss` claim.
	Issuer string
	// Audience, when non-empty, is matched against the token's `aud`.
	Audience string

	// RequireExpiry / RequireIssuer / RequireAudience demand presence of
	// the corresponding claim (independent of the string fields' value
	// matching). OIDC profiles should set all three.
	//
	// A-05: RequireIssuer/RequireAudience are presence-only and would accept
	// ANY value unless the matching Issuer/Audience field is also set. Setting
	// RequireIssuer with an empty Issuer (or RequireAudience with an empty
	// Audience) is a misconfiguration that VerifyJWTWithJWKS rejects with
	// ErrVerifierMisconfigured rather than silently authorizing arbitrary
	// issuers/audiences. RequireExpiry has no value field and is exempt.
	RequireExpiry   bool
	RequireIssuer   bool
	RequireAudience bool

	// AllowedAlgs, when non-empty, overrides AllowedAsymmetricAlgs.
	// `none` and HMAC family algs are rejected regardless of override.
	AllowedAlgs []jwa.SignatureAlgorithm

	// AcceptableSkew is the clock difference tolerated when "exp", "nbf"
	// and "iat" are validated (jwt.WithAcceptableSkew): a token whose "iat"
	// or "nbf" is up to AcceptableSkew in the future, or whose "exp" passed
	// up to AcceptableSkew ago, is still accepted. Zero, the default,
	// tolerates no difference; a negative value is treated as zero.
	AcceptableSkew time.Duration
}

JWKSVerifyOptions configures VerifyJWTWithJWKS.

type JWTVerifyOptions

type JWTVerifyOptions struct {
	// Issuer is the expected "iss" claim value. Empty disables issuer-value
	// comparison (see RequireIssuer for presence enforcement).
	Issuer string
	// Audience is the expected "aud" claim value. Empty disables
	// audience-value comparison (see RequireAudience for presence enforcement).
	Audience string

	// RequireExpiry rejects any token that omits the "exp" claim.
	// RFC 7519 §4.1.4 marks exp OPTIONAL, but OIDC resource servers
	// MUST reject non-expiring access tokens (a never-expiring
	// bearer token is an authorization-server compromise primitive).
	RequireExpiry bool

	// RequireIssuer rejects any token that omits the "iss" claim.
	// Has no effect when Issuer == "" (no expected value to compare
	// against). When both Issuer != "" and RequireIssuer == true,
	// the claim must be present AND match.
	RequireIssuer bool

	// RequireAudience rejects any token that omits the "aud" claim.
	// Has no effect when Audience == "". When both Audience != "" and
	// RequireAudience == true, the claim must be present AND match.
	RequireAudience bool

	// RequireSubject rejects any token whose "sub" claim is absent,
	// non-string, or empty after trimming whitespace. Unlike
	// RequireIssuer/RequireAudience it has no companion expected-value
	// field — it is a pure presence-and-non-emptiness check, since "sub" is
	// per-caller by design. Generated servers set this from
	// security.csrf.enabled (SEC-0061): the CSRF session binding is derived
	// from "sub", and an empty subject is indistinguishable from "no
	// session" (csrfx.Verify now refuses that fail-closed via
	// ErrCSRFUnbound), so refusing it here closes the gap at its source.
	RequireSubject bool
}

JWTVerifyOptions are optional constraints for issuer/audience claim checks.

The boolean Require* fields demand presence of the corresponding claim: a missing claim returns ErrAuthFailed regardless of the string fields' content. Use the Require* form for OIDC resource-server profiles where RFC 6749 / OpenID Connect Core mandate the claim. Without Require*, a missing claim is silently accepted (legacy backwards-compatible behavior — see GENERATOR_BUGS.md "OIDC capability gap" A-S2).

type MTLSPolicy

type MTLSPolicy struct {
	// Required, when true, demands a peer certificate be presented and
	// verified. When false, requests that omit a client cert are allowed
	// through; this matches the tls.VerifyClientCertIfGiven listener mode.
	Required bool
	// EKUValidation, when true, demands the leaf certificate carry the
	// x509.ExtKeyUsageClientAuth extended key usage (or ExtKeyUsageAny).
	EKUValidation bool
	// SupportedIssuers is plumbed through for caller-side issuer-label
	// policy (PIV/CAC/custom). VerifyMTLS does NOT enforce this slice;
	// the mapping from label to certificate Subject/Issuer is deployment-
	// specific and must be supplied via the opts.AuthMTLS hook.
	SupportedIssuers []string
	// Runtime carries the boot-constructed CRL/OCSP revocation checkers and
	// CAC/PIV certificate-policy verifier for this route (L-51). The zero
	// value (all nil fields) enforces nothing beyond the fields above,
	// matching pre-L-51 behavior exactly.
	Runtime MTLSRuntime
	// Workload, when non-nil, is the resource server's workload verifier
	// (security.resource_server.workload_auth; SONNY-794, Task 26). After
	// every other check passes, the certificate must be a workload it
	// accepts (a trusted SPIFFE ID), or the request is refused; the
	// workload it resolves is recorded on the request context
	// (ContextWithMTLSWorkload) and reaches Caller.Workload. The generator
	// sets it on every certificate leaf (mtls, cac, piv) of a server
	// generated with workload_auth, and refuses such a route that also
	// declares principal_mapping or cac_piv: both would name the
	// certificate's principal. On a hand-built policy with both, both must
	// pass and the workload identity is the one recorded. A non-nil
	// Workload implies Required: a request without a client certificate is
	// refused (ErrMTLSCertMissing) even when Required is false. Nil (the
	// default) checks no workload.
	Workload *WorkloadVerifier
}

MTLSPolicy is the per-route mTLS verification policy emitted by the apic code generator and consumed by VerifyMTLS at request time. The shape mirrors the MTLSContract block in the apic config.

type MTLSRuntime added in v0.17.0

type MTLSRuntime struct {
	// CRL, when non-nil, is consulted after EKU validation. A non-nil
	// error (including ErrCertRevoked from a hard-fail checker) rejects
	// the request. A checker constructed with AllowSoftFail:true instead
	// returns nil on fetch failure, which is exactly the "warn and
	// continue" behavior L-51 requires for allow_soft_fail:true.
	CRL RevocationChecker
	// OCSP mirrors CRL for OCSP-based revocation checks. When both CRL and
	// OCSP are configured, CRL is checked first.
	OCSP RevocationChecker
	// CertPolicy, when non-nil, is invoked AFTER CRL/OCSP checks pass. It
	// enforces cac_piv policy (require_person / required_policy_oids /
	// reject_unknown_classification) and resolves principal_mapping. On
	// success VerifyMTLSRequest records the returned principal and
	// classification on the request context (MTLSPrincipalFromContext) and,
	// when the verifier also implements CertPolicyContextVerifier, lets it
	// attach its richer identity object as well (the cacpiv adapter stores
	// the full *mtlsx.Principal, readable via mtlsx.From) -- L-55.
	CertPolicy CertPolicyVerifier
}

MTLSRuntime bundles the boot-constructed, per-route revocation checkers and CAC/PIV policy verifier that VerifyMTLS enforces IN ADDITION to Required/EKUValidation/SupportedIssuers (L-51). The zero value performs no additional checks, so embedding it in MTLSPolicy is fully backward compatible with the pre-L-51 policy shape.

CRLChecker/OCSPChecker hold response caches and make network calls, so the generated server constructs each route's MTLSRuntime exactly ONCE at boot (server_lib.go.tmpl) and shares the same instance across every request for that route -- never reconstruct one per request.

type OwnerExtractor

type OwnerExtractor func(r *http.Request) (ownerID string, ok bool)

OwnerExtractor pulls the resource-owner identifier out of an inbound request — typically a path parameter such as {userId}. It returns the owner id and true when one is present, or false when the request carries no owner-scoped resource (in which case RequireOwnership lets the request through to be handled by other authz layers). The extractor MUST NOT trust any value from the request body for the owner id; it should read the routed path/segment so it cannot be spoofed independently of the route.

type RequestAuthPolicy

type RequestAuthPolicy struct {
	// AllowAuthorizationHeader accepts a credential presented in the normal
	// HTTP "Authorization" header (Bearer or Signature).
	AllowAuthorizationHeader bool
	// AllowSubprotocolBearer accepts a bearer token carried in the WebSocket
	// Sec-WebSocket-Protocol subprotocol list (the browser WS API cannot set
	// arbitrary headers, so the token rides the subprotocol negotiation).
	AllowSubprotocolBearer bool
	// AllowSubprotocolSignature accepts an HTTP-signature credential carried
	// in the WebSocket Sec-WebSocket-Protocol subprotocol list.
	AllowSubprotocolSignature bool
	// AllowQueryBearer accepts a bearer token supplied as a query-string
	// parameter. Off by default in the hardened policy: query strings leak
	// into access logs, proxies, and Referer headers.
	AllowQueryBearer bool
	// AllowQuerySignature accepts an HTTP-signature credential supplied as a
	// query-string parameter (same logging-exposure caveat as AllowQueryBearer).
	AllowQuerySignature bool
}

RequestAuthPolicy controls which alternate auth transports are accepted when a request cannot set normal Authorization headers directly.

func DefaultRequestAuthPolicy

func DefaultRequestAuthPolicy() RequestAuthPolicy

DefaultRequestAuthPolicy is the hardened default used by generated servers.

type RevocationChecker added in v0.17.0

type RevocationChecker interface {
	Check(ctx context.Context, leaf, issuer *x509.Certificate) error
}

RevocationChecker checks whether a leaf certificate has been revoked by its issuer (CRL or OCSP). Concrete implementations live in pkg/securex/mtlsx (CRLChecker / OCSPChecker); MTLSPolicy.Runtime references only this interface so that this package never imports mtlsx (mtlsx already imports securex -- importing back would cycle). AllowSoftFail behavior (warn-and-continue vs hard-fail) is baked into the concrete checker at construction time, not re-decided here: L-51 requires allow_soft_fail:false to hard-fail and the checker itself is the single source of truth for that decision.

type Role

type Role int

Role represents an RBAC privilege level. Higher values mean greater privilege.

const (
	// RoleUser is the lowest privilege level in the default hierarchy.
	// (RoleFromString and Parse return RoleNone, with an error, for an
	// unknown name; RoleUser before.)
	RoleUser Role = iota // lowest privilege (default)
	// RoleManager is the intermediate privilege level in the default
	// hierarchy (above RoleUser, below RoleAdmin).
	RoleManager // intermediate privilege
	// RoleAdmin is the highest privilege level in the default hierarchy.
	RoleAdmin // highest privilege
)
const RoleNone Role = -1

RoleNone is the rank of a claim set that holds no role the vocabulary knows: no roles claim, an empty one, or only names the vocabulary never declared. It ranks below every role, and HasRole never admits it, so a hierarchy check ("at least role R") never admits a caller with no known role, whatever R is (SONNY-3221). Before, Highest and HighestRole gave such a caller the LOWEST rank, so a route requiring the lowest role of the vocabulary admitted every authenticated caller; with security.roles ["owner", "admin"], the admin-surface default "admin" admitted a token with no roles at all. An exact-role check (callerctx.Caller.HasRole, the MCP role gate) is unaffected: it never ranked anything.

func RoleFromString

func RoleFromString(s string) (Role, error)

RoleFromString parses a case-insensitive role name against the BUILT-IN admin > manager > user vocabulary. Returns RoleNone and ErrInvalidConfig for unknown names. A server with a custom vocabulary must call (*RoleHierarchy).Parse on its own hierarchy instead — this function has no way to see it, which is precisely why the process-wide SetRoleHierarchy was removed (ENG-4634).

func (Role) HasRole

func (r Role) HasRole(required Role) bool

HasRole returns true if r has at least the privilege level of required. Implements the role hierarchy: admin ≥ manager ≥ user. It fails closed on a rank below zero on either side: RoleNone (a caller holding no known role) satisfies no requirement, and no caller satisfies a requirement below zero (SONNY-3221).

func (Role) String

func (r Role) String() string

String returns the canonical name of a role in the BUILT-IN vocabulary. RoleNone renders as "none"; other ranks outside the vocabulary render as "user" (the historical fallback). Use (*RoleHierarchy).Name for a custom vocabulary.

type RoleHierarchy added in v0.19.2

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

RoleHierarchy is an ordered, case-insensitive RBAC role vocabulary. It is immutable after construction and therefore safe for concurrent use, so two servers in one process may hold DIFFERENT vocabularies without either one re-ranking the other's roles (ENG-4634 / GitLab #364).

It replaces the package-level roleRank/roleNames pair that SetRoleHierarchy used to overwrite: that pair was one process-wide graph, so a second generated server's init() silently re-ranked the first server's roles — and the generator's own emitter documented the limitation as "composing services with DIFFERENT role vocabularies into one binary is unsupported". It is supported now: hand each surface its own hierarchy.

func DefaultRoleHierarchy added in v0.19.2

func DefaultRoleHierarchy() *RoleHierarchy

DefaultRoleHierarchy returns the built-in admin > manager > user vocabulary that RoleFromString, Role.String and Claims.HighestRole resolve against. Generated servers that declare a custom security.roles list build their own with NewRoleHierarchy instead.

func MustRoleHierarchy added in v0.19.2

func MustRoleHierarchy(namesHighestFirst []string) *RoleHierarchy

MustRoleHierarchy is NewRoleHierarchy for a vocabulary that is known good at build time — the built-in default, and the generated `var _roleHierarchy` apic emits from security.roles (which the generator already validated). It panics rather than returning an error so a malformed vocabulary can never degrade into "every role unparseable", i.e. a server that 403s every RBAC-gated route at runtime instead of refusing to start.

func NewRoleHierarchy added in v0.19.2

func NewRoleHierarchy(namesHighestFirst []string) (*RoleHierarchy, error)

NewRoleHierarchy builds a vocabulary from names ordered MOST-PRIVILEGED FIRST (index 0 = highest privilege), matched case-insensitively. It fails closed on an empty list, a blank name, or a case-insensitive duplicate — each of which would otherwise produce a silently wrong privilege ordering. (SetRoleHierarchy skipped blanks and let a duplicate overwrite a rank, leaving a hole in roleNames and two names sharing one rank.)

func (*RoleHierarchy) Highest added in v0.19.2

func (h *RoleHierarchy) Highest(c *Claims) Role

Highest returns the most privileged role in c that this vocabulary knows. Unknown role strings are ignored — a role name this vocabulary never declared cannot grant privilege — and a nil claim set, or one holding no role this vocabulary knows (no roles claim, an empty one, or only unknown names), yields RoleNone, which HasRole never admits (SONNY-3221; it yielded the lowest rank, RoleUser, before). Claims.HighestRole matches.

func (*RoleHierarchy) Name added in v0.19.2

func (h *RoleHierarchy) Name(r Role) string

Name returns the canonical (lowercased) name for a rank in this vocabulary, or "" when the rank is outside it.

func (*RoleHierarchy) Names added in v0.19.2

func (h *RoleHierarchy) Names() []string

Names returns this vocabulary's role names ordered MOST-PRIVILEGED FIRST — the same order NewRoleHierarchy consumed. The returned slice is a copy.

func (*RoleHierarchy) Parse added in v0.19.2

func (h *RoleHierarchy) Parse(s string) (Role, error)

Parse resolves a case-insensitive role name against this vocabulary. A nil receiver resolves against the built-in default so a zero-valued consumer fails the same way it always did rather than panicking.

An unknown name returns RoleNone with ErrInvalidConfig, so a caller that ignores the error still fails closed: RoleNone satisfies no requirement, and no role satisfies it (Role.HasRole). It returned RoleUser, the lowest real rank, before (SONNY-794, Task 26 fix M8): a caller that dropped the error would have ranked an unknown holder as a user, or turned an unknown requirement into "any user".

type WebhookAlg

type WebhookAlg string

WebhookAlg names the HMAC family the verifier accepts. The generator emits an allowlist literal per route; the verifier rejects every alg outside the allowlist, defending against downgrade attempts where a sender stamps `alg=md5` and we silently honour it.

const (
	// WebhookAlgSHA256 selects HMAC-SHA256 for webhook signature
	// verification. This is the default when a request omits the
	// signature-algorithm header and the default allowlist member when
	// WebhookPolicy.Algs is empty.
	WebhookAlgSHA256 WebhookAlg = "sha256"
	// WebhookAlgSHA512 selects HMAC-SHA512 for webhook signature
	// verification. Must be explicitly listed in WebhookPolicy.Algs to be
	// accepted; it is not part of the implicit default allowlist.
	WebhookAlgSHA512 WebhookAlg = "sha512"
)

type WebhookPolicy

type WebhookPolicy struct {
	// Name is the operator-facing webhook name (e.g. "stripe", "github").
	// Passed to APIOptions.AuthWebhook to fetch the secret and recorded
	// in audit events.
	Name string

	// Algs is the HMAC family allowlist. Empty (nil/zero-length) means
	// "sha256 only" so a config that forgets to set this still defaults
	// to a safe value. The verifier compares case-insensitively against
	// the value carried in the X-Signature-Alg header (or the default
	// sha256 when absent).
	Algs []WebhookAlg

	// Window bounds the |now - timestamp| acceptable skew. A zero value
	// is treated as 5*time.Minute by VerifyWebhook (Task 2). The
	// 5-minute default matches the industry de-facto window for
	// HMAC-signed webhooks (Stripe, GitHub, PagerDuty).
	Window time.Duration

	// MaxBodyBytes caps the body the verifier will hash. Zero resolves
	// to defaultHMACBodyLimit (1 MiB) at codegen.
	MaxBodyBytes int64

	// SignatureHeader is the header the signature lives in. Defaults
	// to "X-Signature" when empty. Generator emits the configured
	// override (e.g. "X-Hub-Signature-256") verbatim.
	SignatureHeader string

	// TimestampHeader is the unix-seconds header. Defaults to
	// "X-Timestamp" when empty.
	TimestampHeader string

	// IDHeader is the per-event id header used for replay protection.
	// Defaults to "X-Webhook-Id" when empty. The (id, timestamp) tuple
	// goes into the package-shared replay map so an attacker cannot
	// re-deliver an intercepted POST.
	IDHeader string
}

WebhookPolicy is the verification contract for a single webhook receiver route. The generator emits a literal of this struct directly into the per-route handler. Secret resolution is external: APIOptions.AuthWebhook(name) returns the HMAC key at request time, keyed on the WebhookPolicy.Name field below.

type WorkloadOption added in v0.24.0

type WorkloadOption func(*workloadOptions)

WorkloadOption configures NewWorkloadVerifier.

func WithListenerCAs added in v0.24.0

func WithListenerCAs(pool *x509.CertPool) WorkloadOption

WithListenerCAs supplies the listener's client CA pool (server.tls.mtls_ca_path). It is the fallback bundle of a configuration that accepts exactly ONE trust domain and gives it no trust_bundles entry: every CA in that pool can then assert the trust domain. With more than one trust domain it is never used; each needs its own bundle. A nil pool is ignored.

func WithTrustBundle added in v0.24.0

func WithTrustBundle(trustDomain string, roots *x509.CertPool) WorkloadOption

WithTrustBundle supplies the CA bundle of one trust domain: a workload whose SPIFFE ID names trustDomain is accepted only when its certificate chains to roots. The generated server loads each security.resource_server.workload_auth.trust_bundles entry with it. A nil pool is ignored.

type WorkloadVerifier added in v0.24.0

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

WorkloadVerifier decides which TLS client certificates, among those the listener has already verified against its CA pool, are workload principals, and maps each one it accepts to a callerctx.Workload. It is built once from security.resource_server.workload_auth and is safe for concurrent use: everything it reads is copied at construction.

The trust constraint always applies to the certificate's SPIFFE ID, the one URI SAN an X.509-SVID carries: its trust domain must be one of trust_domains, or the whole ID one of allowed_spiffe_ids (exact string comparison), and the certificate must chain to that trust domain's OWN CA bundle (trust_bundles; SPIFFE Trust Domain and Bundle §4), so a CA of one trust domain cannot assert another's workloads. subject_from only chooses which field names the workload's subject. With "cn", "upn" or "san_uri" that field is not what the constraint checked: it is read from a certificate whose SPIFFE ID passed, and so is attested by the same CA-verified certificate, but two workloads of one trusted trust domain can carry the same common name or UPN. Authorize on the SPIFFE ID (callerctx.Workload.SPIFFEID), or keep subject_from "spiffe", when that matters.

func NewWorkloadVerifier added in v0.24.0

func NewWorkloadVerifier(cfg *configx.WorkloadAuthConfig, opts ...WorkloadOption) (*WorkloadVerifier, error)

NewWorkloadVerifier builds the verifier for cfg. It refuses an absent or disabled block (ErrWorkloadAuthDisabled), then a block cfg.Validate refuses: an enabled block must constrain the accepted peers with trust_domains or allowed_spiffe_ids, never "any certificate the TLS layer accepted", and with more than one accepted trust domain (cfg.AcceptedTrustDomains) must name a trust_bundles entry for each.

Every accepted trust domain then needs its CA bundle (ErrWorkloadTrustBundle otherwise): the one WithTrustBundle supplies for it, which every trust_bundles entry needs; or, for the single accepted trust domain of a configuration that gives it no entry, the listener's pool (WithListenerCAs; ListenerCAsAssertTrustDomain then reports true). A WithTrustBundle for a trust domain the configuration does not accept is refused too. The generated server calls it only when security.resource_server is enabled too.

func (*WorkloadVerifier) Authenticate added in v0.24.0

func (v *WorkloadVerifier) Authenticate(r *http.Request) (*http.Request, error)

Authenticate verifies r (Verify) and returns it carrying the workload on its context (ContextWithMTLSWorkload), where CallerFromRequest merges it into Caller.Workload -- the path every mTLS principal takes (MTLSPrincipalFromContext), never installed as claims, so the workload neither retires a verified Bearer caller nor enters the one-identity comparison. On an error r is returned unchanged.

func (*WorkloadVerifier) ListenerCAsAssertTrustDomain added in v0.24.0

func (v *WorkloadVerifier) ListenerCAsAssertTrustDomain() bool

ListenerCAsAssertTrustDomain reports whether the verifier's one trust domain has no bundle of its own and falls back to the listener's client CA pool (WithListenerCAs): every CA in server.tls.mtls_ca_path can then assert that trust domain. Nil-safe.

func (*WorkloadVerifier) Verify added in v0.24.0

Verify returns the workload the request's verified TLS client certificate identifies, or an error, failing closed at every step:

  1. r must carry a peer certificate the listener verified (r.TLS.VerifiedChains non-empty), or it is ErrNoClientCertificate. The leaf is the certificate the peer presented (ClientCertificateLeaf), which must also start every verified chain, or it is ErrClientCertificateMismatch.
  2. The leaf must be an X.509-SVID leaf (X.509-SVID §4.1, §4.3): not a CA, without the keyCertSign or cRLSign key usage, and, when it has a key usage extension, asserting digitalSignature, or it is ErrWorkloadNotAllowed.
  3. The leaf must carry exactly one URI SAN (X.509-SVID §2), read as the certificate encodes it (CertificateURISANs), never as net/url re-serialises it, and it must be a SPIFFE ID (ParseSPIFFEID), or it is ErrSPIFFEMalformed. An uppercase scheme or trust domain, userinfo, a port, a query, a fragment (even an empty one) or a percent-escape are all malformed.
  4. The ID must have a path (a bare spiffe://<trust-domain> names the trust domain, never a workload), and its trust domain must be trusted or the ID allowed exactly, or it is ErrWorkloadNotAllowed.
  5. The certificate must chain, through the intermediates the peer presented, to the CA bundle of the ID's OWN trust domain (WithTrustBundle, or the listener's pool for a single trust domain without one), for client authentication, or it is ErrWorkloadNotAllowed: the listener verified it against every CA it trusts, which is not the same thing.
  6. The subject subject_from selects must be non-empty and free of control, bidirectional and invisible characters (HasIdentityControl), or it is ErrWorkloadNotAllowed.

The workload's SPIFFEID is the ID, Subject the selected field, and Issuer the trust domain's own SPIFFE ID (spiffe://<trust-domain>).

Directories

Path Synopsis
Code generated by apic; DO NOT EDIT.
Code generated by apic; DO NOT EDIT.
Code generated by apic; DO NOT EDIT.
Code generated by apic; DO NOT EDIT.
Code generated by apic; DO NOT EDIT.
Code generated by apic; DO NOT EDIT.
Package harness provides RBAC/ABAC test helpers for downstream services that consume the generated API.
Package harness provides RBAC/ABAC test helpers for downstream services that consume the generated API.
pkg/securex/hashx/algorithm.go Package hashx implements salted, self-describing, FIPS-140-3-aware one-way hashing and constant-time verification for passwords and other authentication material.
pkg/securex/hashx/algorithm.go Package hashx implements salted, self-describing, FIPS-140-3-aware one-way hashing and constant-time verification for passwords and other authentication material.
Code generated by apic; DO NOT EDIT.
Code generated by apic; DO NOT EDIT.
Package sessionx implements FedRAMP AC-7 account lockout, AC-11 inactivity timeout, and AC-12 session termination tracking.
Package sessionx implements FedRAMP AC-7 account lockout, AC-11 inactivity timeout, and AC-12 session termination tracking.
Code generated by apic; DO NOT EDIT.
Code generated by apic; DO NOT EDIT.
pkcs11
Code generated by apic; DO NOT EDIT.
Code generated by apic; DO NOT EDIT.
softfile
Code generated by apic; DO NOT EDIT.
Code generated by apic; DO NOT EDIT.
Code generated by apic; DO NOT EDIT.
Code generated by apic; DO NOT EDIT.

Jump to

Keyboard shortcuts

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