Skip to content

feat: pluggable authentication — static keys + OIDC + sidecar broker (closes SKA-275)#2

Open
mfacenet wants to merge 3 commits intomainfrom
feat/ska-278-auth-static-keys
Open

feat: pluggable authentication — static keys + OIDC + sidecar broker (closes SKA-275)#2
mfacenet wants to merge 3 commits intomainfrom
feat/ska-278-auth-static-keys

Conversation

@mfacenet
Copy link
Copy Markdown
Contributor

@mfacenet mfacenet commented May 7, 2026

Summary

Closes the auth gap that left the API server accepting unauthenticated requests after K8sLeaseStore made it deployable. Lands all three pieces of the auth story together so the operational picture is reviewable end-to-end.

Commit Ticket What it does
44463fd SKA-278 Hash-backed static API keys + AuthMiddleware + SIGHUP reload. Foundational; defines the Authenticator interface used by everything below.
0e1f3df SKA-279 Server-side OIDC authenticator for any third-party IdP (Okta, PingFederate, Entra/Azure AD, Auth0, Dex, Keycloak, AWS Cognito, GitHub OIDC, etc.).
6cf4dd9 SKA-283 Operator-side: --berth-api-key-file with reload + cmd/berth-oidc-broker reference token broker for the sidecar pattern.

Together these close SKA-275 (the parent — pluggable authentication for the Berth API server). SKA-280 (SPIFFE/SPIRE) remains explicitly long-term-deferred.

End-to-end flow this enables

Okta / PingFederate / Entra / Auth0
            │ client_credentials
            ▼
┌────────────────────┐    writes JWT     ┌────────────────────┐
│ berth-oidc-broker  │ ─── (atomic) ───▶ │ /var/run/berth/    │
│ (sidecar)          │   to shared vol   │  token             │
└────────────────────┘                   └────────────────────┘
                                                   │
                                                   │ --berth-api-key-file
                                                   ▼
                                         ┌────────────────────┐
                                         │ berth-operator     │
                                         │ (1s cache TTL,     │
                                         │  picks up rotation)│
                                         └────────────────────┘
                                                   │ Bearer JWT
                                                   ▼
                                         ┌────────────────────┐
                                         │ berth-apiserver    │
                                         │ --auth-mode=oidc   │
                                         │ validates iss/aud/ │
                                         │ exp/sig/claims     │
                                         └────────────────────┘

Result: cross-cluster operator auth with no long-lived secret on the operator side beyond the IdP's client-credentials Secret. Token rotation is non-disruptive (the operator's FileTokenSource re-reads on a 1-second cache TTL; the broker's atomic write means readers never see partial files).

Auth modes available

--auth-mode= selects between three implementations of the Authenticator interface in internal/auth:

  • none — accepts every request. Logs a loud WARN at startup. Default only when --coordination-namespace is empty (i.e., dev mode using the in-memory store).
  • static-keys — file of <key-id>:<sha256-hex> entries, hashed at rest. SIGHUP reloads in place; preserve-on-error means a botched edit can't lock you out.
  • oidc — JWT validation against an OIDC provider's JWKS. Supports --oidc-required-claim=key=value for coarse authorization, custom username/tenant claim mapping, and JWKS URL override.

Switching backends is config-only — no code change required.

Operator changes

  • New --berth-api-key-file <path> flag, mutually exclusive with --berth-api-key. Existing static-key callers see no behavior change.
  • New internal/operator.FileTokenSource reads the file with a small (1s) cache and returns the previously-cached value on transient read errors so a sidecar restart doesn't take down the reconcile loop.

Reference broker

cmd/berth-oidc-broker (~250 LOC) is a small purpose-built sidecar:

  • Discovers the IdP's token endpoint via OIDC.
  • OAuth2 client-credentials grant (with audience form parameter for Auth0/some Okta setups, scopes for everything else).
  • Atomic writes to the shared volume (temp file + rename, mode 0600 — readers never see a partial file).
  • Refreshes well before expiry; retries on a min-refresh cadence on failure so a transient IdP outage doesn't tear down the broker.

For exotic flows (token exchange, mTLS-bound tokens) users can substitute their own broker — the operator only cares that the file at --berth-api-key-file contains a valid bearer token.

Test plan

  • go test ./... -race -count=1 — all packages green
  • make lint — golangci-lint + go vet clean
  • make build — all four binaries (apiserver, operator, berth, berth-oidc-broker) build
  • Each commit on the branch builds and tests in isolation
  • OIDC tests verify: happy path, expired tokens, wrong audience/issuer, bad signature (token signed with a key not in the JWKS), required-claim enforcement (string + array forms), custom claim mapping, generic-error oracle resistance, unreachable-issuer fast-fail
  • Static keys tests verify: hashing on construction, file format parsing (with rejection for malformed lines, duplicate hashes, etc.), SIGHUP-equivalent reload preserves old state on parse error, generic error message
  • Broker tests verify: arg validation, secret loading from literal vs file, token-URL discovery vs override, refresh-interval math, atomic writes (no leftover temp files), end-to-end run-loop against a fake token endpoint
  • Operator file token source tests verify: missing file fast-fail, empty-file rejection, whitespace trim, rotation pickup after TTL, read-error fallback to cached value
  • Manual integration against a real Okta/PingFederate tenant — deferred to SKA-276 (e2e tests)

Out of scope

  • SPIFFE/SPIRESKA-280, explicitly long-term-deferred. mTLS via the SPIFFE Workload API would remove the IdP client-credentials secret entirely but adds significant infra (SPIRE server + agents on every cluster). Revisit when one of the trigger conditions in SKA-280 fires.
  • Helm chart wiring for the new flagsSKA-281 / SKA-282.
  • OAuth2 chained authenticators (try OIDC, fall back to static-keys) — possible follow-up; v1 makes them mutually exclusive.

Files of note

  • internal/auth/static.go — hash-backed static key authenticator
  • internal/auth/oidc.go — go-oidc-backed JWT validator
  • internal/auth/noauth.goNoOpAuthenticator for tests/composition
  • internal/api/middleware.goAuthMiddleware, IdentityFromContext
  • internal/operator/tokensource.goFileTokenSource for the operator
  • pkg/client/options.goWithAPIKey (back-compat) + new WithAPIKeyFunc
  • cmd/berth-oidc-broker/main.go — reference sidecar broker
  • cmd/apiserver/main.go--auth-mode + --api-keys-file + all --oidc-* flags
  • cmd/operator/main.go--berth-api-key-file + mutual-exclusion check
  • README.md — Authentication section with Okta/PingFederate/Entra walkthroughs and the sidecar pod sketch

mfacenet added 3 commits May 7, 2026 14:53
Closes the security gap that left the API server accepting
unauthenticated requests after K8sLeaseStore made it deployable. The
operator already plumbed --berth-api-key as a Bearer token; this wires
the server side to actually validate it.

internal/auth:

- StaticAuthenticator now stores SHA-256 hashes of tokens internally.
  NewStaticAuthenticator(rawTokens) hashes on construction (test
  convenience). NewStaticAuthenticatorFromKeysFile(path) loads the
  on-disk format documented in the README — '<key-id>:<sha256-hex>',
  with comments and blank lines ignored. Authenticate hashes the
  presented token and looks up by hash.
- Reload() re-reads the file atomically; on parse error the previous
  key set is preserved, so a malformed update can't lock the system out.
- Error messages are deliberately generic and never echo the rejected
  token or known key ids, removing the obvious enumeration oracle.
- NoOpAuthenticator added for tests / future composition (chained
  authenticators). Production no-auth mode uses nil authn instead.

internal/api:

- AuthMiddleware reads Authorization: Bearer, calls the Authenticator,
  attaches *auth.Identity to the request context (retrievable via
  IdentityFromContext), and returns a 401 JSON envelope on any failure.
- NewMux signature is NewMux(mgr, authn). When authn is nil the lease
  routes are unauthenticated (dev mode); when non-nil they are wrapped
  by AuthMiddleware. /healthz is always unauthenticated.

cmd/apiserver:

- Adds --auth-mode (none|static-keys) and --api-keys-file flags.
  Default is 'static-keys' when --coordination-namespace is set
  (production), 'none' otherwise (dev). 'none' mode logs a loud warning
  at startup.
- SIGHUP triggers Reload() on the configured authenticator so keys can
  be rotated without restarting the server.
- Exit-code refactor: main() is now a thin wrapper around run() that
  returns int, so config-error exits are testable in principle.

Tests cover the static authenticator hashing, file format, error
messages, reload behavior (including preserve-on-error); the
middleware (success / missing header / wrong scheme / authenticator
error / oracle-resistant body); and end-to-end roundtrips against a
real StaticAuthenticator both via raw HTTP and via the Go client's
WithAPIKey.

README documents the flags, the keys-file format, and the suggested
key-generation + rotation workflow.

Refs: SKA-275
…KA-279)

Adds the server-side OIDC Authenticator that validates JWTs from any
OIDC provider — Okta, PingFederate, Entra/Azure AD, Auth0, GitHub OIDC,
GitLab OIDC, AWS Cognito, Dex, Keycloak. Selected via --auth-mode=oidc.

internal/auth.OIDCAuthenticator wraps github.com/coreos/go-oidc/v3 and
validates signature against the issuer's JWKS (cached and refreshed
on kid-miss by the underlying lib), the standard JWT claims (iss, aud,
exp, nbf), and any caller-supplied required claims. Returns a
deliberately generic error on every failure so a 401 response can't be
used as an oracle to enumerate valid kids/audiences.

cmd/apiserver gains:

  --oidc-issuer-url           OIDC issuer (drives discovery)
  --oidc-audience             expected aud claim
  --oidc-required-claim       repeatable key=value (matches string-valued
                              and array-valued claims)
  --oidc-username-claim       JWT claim copied into Identity.Holder (default sub)
  --oidc-tenant-claim         JWT claim copied into Identity.Tenant (default sub)
  --oidc-jwks-url             optional override of discovered JWKS

The buildAuthenticator switch grows an oidc case alongside none /
static-keys; switching backends is config-only, no code change.

Tests cover happy path, bad signature (token signed by a key not in
the JWKS), expired/wrong-audience/wrong-issuer tokens, required-claim
enforcement (string and array forms), custom username/tenant claim
mapping, generic-error oracle resistance, and a smoke test that
discovery against an unreachable issuer fails fast.

The operator-side companion — short-lived token acquisition via a
sidecar broker — is the next commit (SKA-283).

Refs: SKA-275
… (SKA-283)

The operator-side companion to SKA-279: enables short-lived,
externally-refreshing JWTs in place of the static API key, closing the
"long-lived secret" gap for the OIDC story.

Three layers:

1. pkg/client refactor (back-compat): WithAPIKey is now a closure over
   a static string under the hood, and a new WithAPIKeyFunc accepts a
   getter the client invokes per request. Existing WithAPIKey callers
   see no behavior change. This is what lets the operator pick up
   rotated tokens without restarting.

2. Operator file token source: internal/operator.FileTokenSource reads
   a bearer token from a path with a small TTL cache (default 1s).
   Fails fast at startup if the file is missing/empty/unreadable; on
   later read errors returns the previously-cached value so a transient
   sidecar restart doesn't kill the reconcile loop.

   cmd/operator gains --berth-api-key-file, mutually exclusive with
   --berth-api-key. When set, the operator uses WithAPIKeyFunc(ts.Get).

   Tests cover happy path, missing file, empty file, whitespace
   trimming, rotation pickup after TTL, and read-error fallback.

3. Reference broker (cmd/berth-oidc-broker): a small (~250 LOC)
   token broker intended to run as a sidecar to the operator. Does
   OAuth2 client-credentials against the configured IdP (token endpoint
   discovered via OIDC), writes the access token atomically (temp file
   + rename, mode 0600) to the shared volume, and refreshes well before
   expiry. Supports --oidc-audience as a token-request parameter
   (Auth0, some Okta authorization servers) and --oidc-scopes. On
   failure, retries on a min-refresh cadence so a transient IdP outage
   doesn't tear down the broker.

   Tests cover arg validation, secret loading from literal vs file,
   token-URL discovery vs override, refresh-interval math, atomic
   writes (no leftover temp files), scope parsing, and an end-to-end
   run-loop test against a fake token endpoint.

Plumbing:

- Makefile builds the new berth-oidc-broker binary alongside the others.
- README documents --berth-api-key-file on the operator and shows the
  full sidecar pod sketch (broker + operator + Memory-backed emptyDir).
  Includes guidance for Entra/Cognito/Auth0 audience handling and notes
  that users wanting exotic flows (token exchange, MTLS-bound tokens)
  can substitute their own broker — the operator only cares that the
  file at --berth-api-key-file contains a valid bearer token.

With this commit and SKA-279, the operator can authenticate to the API
server with no long-lived secret on its side beyond the IdP client-
credentials Secret, satisfying SKA-275's pluggable-authentication goal.

Refs: SKA-275
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant