internalauth

package
v0.5.2 Latest Latest
Warning

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

Go to latest
Published: Jul 3, 2026 License: MIT Imports: 9 Imported by: 0

README

internalauth - gateway-to-service identity assertion

The gateway mints a short-lived ES256 assertion in the X-Internal-Auth header carrying the verified caller. Services verify it with the gateway's public key via ginx.VerifyGateway and derive X-User-Id from the signed subject. Only the gateway holds the private key, so only the gateway can mint one. A compromised service holds the public key alone and cannot forge identity. Internal service-to-service calls forward the inbound assertion via identity.ForwardGatewayToken rather than minting their own.

Environment

Var Where Secret? Meaning
INTERNAL_AUTH_PRIVATE_KEY gateway only yes base64 or raw PEM EC private key the gateway signs with
INTERNAL_AUTH_KID gateway only no key id stamped into each assertion's header
INTERNAL_AUTH_PUBLIC_KEYS gateway + every service no base64 or raw JSON {kid: PEM} keyset used to verify
INTERNAL_AUTH_MODE any no set to permissive to run without signing or verification, insecure

Fail-closed: a service with no INTERNAL_AUTH_PUBLIC_KEYS, or the gateway with no INTERNAL_AUTH_PRIVATE_KEY, refuses to boot unless INTERNAL_AUTH_MODE=permissive is set explicitly. So the control can't be left off by accident. permissive is the deliberate opt-in for early rollout or a keyless dev box.

Boot self-test: give the gateway INTERNAL_AUTH_PUBLIC_KEYS too, the same value the services get. At startup it mints a probe and verifies it against the keyset, so a wrong, stale, or half-rotated key crashes the gateway with a clear message instead of 401-ing every live request.

Provisioning

Generate a key pair and the three env values:

scripts/gen-internal-auth-key.sh            # kid defaults to internal-YYYYMMDD
scripts/gen-internal-auth-key.sh prod-2026  # or pass an explicit kid
  • Put INTERNAL_AUTH_PRIVATE_KEY and INTERNAL_AUTH_KID in your secret manager and inject them into the gateway only.
  • Give INTERNAL_AUTH_PUBLIC_KEYS, which is not secret, to the gateway and every service. It must be byte-identical everywhere.

Dev uses a throwaway key in .secrets/dev_gateway.env, gitignored, plus the public keyset inline in docker-compose.dev.yaml. Prod in docker-compose.yaml requires all three via ${VAR:?} so a missing value fails compose-up and never ships open.

maestro caveat: the compose service blocks are maestro-generated. The INTERNAL_AUTH_* env lines and the # No host ports comments are hand-added, so re-apply them after maestro refresh regenerates the file, the same way the host-port removals are maintained. The dev private key in .secrets/dev_gateway.env is a separate file maestro does not own.

Rotating the key

The keyset is a multi-key map {kid: PEM} and assertions are verified by kid, so rotation is a zero-downtime overlap, never a flip:

  1. Generate a new pair with a new kid: scripts/gen-internal-auth-key.sh new-kid.
  2. Publish both public keys: merge the new entry into INTERNAL_AUTH_PUBLIC_KEYS on the gateway and every service so the keyset holds both the old and new kid. Roll that out everywhere first. Old assertions still verify and new verifiers already trust the new key.
  3. Switch the gateway to the new INTERNAL_AUTH_PRIVATE_KEY and INTERNAL_AUTH_KID. It now signs with the new key, and its boot self-test passes because the keyset holds the new key.
  4. Wait one assertion TTL, internalauth.TTL of about 60s plus ClockSkewLeeway, so every in-flight old-kid assertion has expired.
  5. Drop the old key from INTERNAL_AUTH_PUBLIC_KEYS everywhere.

Rolling back is the same sequence in reverse. Because both keys are trusted during the overlap, there's no window where valid assertions are rejected.

Residual risk

Internal hops authenticate by forwarding the gateway assertion, since the gateway is the only signer. So within its TTL + ClockSkewLeeway window a captured, unmodified assertion is replayable by something already on the internal network. It can't be altered, because of the signature, or forged, because there's no private key. The bound is the short lifetime and the gateway-sole-ingress assumption. Per-hop identity or replay-proofing would need aud/jti binding or mTLS.

Documentation

Overview

Package internalauth is the gateway <-> service identity assertion: the gateway mints a short-lived ES256 token asserting the verified caller, and services verify it with the gateway's PUBLIC key before trusting that identity. It wraps the shared jwtx primitives and loads keys from the environment.

Only the gateway holds the private key, so only the gateway can mint an assertion - a compromised service holds the public key alone and cannot forge one. Internal service-to-service calls forward the inbound assertion (see identity.GatewayToken) rather than minting, so caller identity always traces back to a single gateway signature.

Index

Constants

View Source
const (
	EnvPrivateKey = "INTERNAL_AUTH_PRIVATE_KEY"
	EnvKID        = "INTERNAL_AUTH_KID"
	EnvPublicKeys = "INTERNAL_AUTH_PUBLIC_KEYS"
	// EnvMode, set to ModePermissive, is the explicit opt-in to run WITHOUT
	// gateway-assertion verification/signing. Without it, a missing key is a
	// startup error rather than a silent trust-on-network-isolation
	// downgrade - so the security control can't ship off by accident.
	EnvMode        = "INTERNAL_AUTH_MODE"
	ModePermissive = "permissive"
)

Env var names. The private key is secret; the public keyset is not.

View Source
const ClockSkewLeeway = 60 * time.Second

ClockSkewLeeway tolerates clock drift between the gateway (issuer) and a verifying service when checking the assertion's exp. The assertion is minted on one host and verified on another and forwarded across hops, so strict expiry would 401 on ordinary NTP drift; this gives headroom. It widens the worst-case replay window to TTL+leeway, an internal-network-only exposure already bounded by the gateway-sole-ingress assumption.

View Source
const TTL = 60 * time.Second

TTL is how long a minted assertion stays valid. It only needs to outlive a single request's downstream fan-out (gateway -> service -> service), so it's kept short to bound the replay window if one is ever captured.

Variables

This section is empty.

Functions

func Check

func Check(v *jwtx.Verifier, token string) (sub string, role identity.Role, ok bool)

Check verifies an assertion's signature, expiry, issuer and purpose, returning the asserted subject, the caller's staff role, and ok=true. A bad/expired/foreign token returns ok=false. An out-of-range role claim is clamped to RoleUser so a malformed assertion never yields a privileged tier.

func Middleware

func Middleware() (gin.HandlerFunc, error)

Middleware builds the gateway-assertion verification middleware from the environment, so every service mounts it identically in one line:

mw, err := internalauth.Middleware()
...
r.Use(..., mw)

Centralizing the VerifierFromEnv + VerifyFunc + VerifyGateway wiring here means a change to how services verify (a metrics hook, a required-key mode) lands in one place rather than being re-edited across every service. A malformed keyset surfaces as the returned error.

func Mint

func Mint(s *jwtx.Signer, sub string, role identity.Role) (string, error)

Mint signs an assertion for sub (the verified caller id, or "" for a public request that carries no user - the assertion then only proves gateway origin) carrying the caller's staff role. role travels in the signed claim, so a downstream service derives the tier from the gateway's signature rather than a spoofable header - the same trust path as the subject. It signs a minimal claim set directly (iss/sub/role/exp + purpose); unlike the jwtx Issue* helpers it omits a per-token jti, which an ephemeral assertion has no use for and which would cost a crypto/rand read on every proxied request.

func SelfCheck

func SelfCheck(s *jwtx.Signer, v *jwtx.Verifier) error

SelfCheck confirms the gateway's signing key and the published verifying keyset actually correspond: it mints a probe assertion and verifies it. A mismatch (wrong kid, a key rotated on one side only, the wrong keyset) fails here at startup, loudly, instead of surfacing as opaque 401s on the first real request. Call it only when both halves are configured.

func SignerFromEnv

func SignerFromEnv() (*jwtx.Signer, error)

SignerFromEnv builds the gateway's assertion signer from EnvPrivateKey (a base64-encoded or raw PEM EC private key) and EnvKID. Returns (nil, nil) when unset, so the gateway can run un-keyed during rollout (it then falls back to injecting a raw X-User-Id and logs a warning).

func VerifierFromEnv

func VerifierFromEnv() (*jwtx.Verifier, error)

VerifierFromEnv builds the assertion verifier from EnvPublicKeys (a base64-encoded or raw JSON {kid: PEM} keyset - jwtx.ParseKeyset handles both). Returns (nil, nil) when unset, so a service can run un-keyed during rollout (ginx.VerifyGateway then passes through with a warning).

func VerifyFunc

func VerifyFunc(v *jwtx.Verifier) func(token string) (string, identity.Role, bool)

VerifyFunc adapts a verifier into the closure ginx.VerifyGateway expects. It returns nil when v is nil (key unset) so VerifyGateway runs in its pass-through rollout mode rather than rejecting every request.

Types

This section is empty.

Jump to

Keyboard shortcuts

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