diff --git a/README.md b/README.md index c61120b..9707d9a 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,12 @@ Normally installed by the **Krateo installer**, which pins the chart. Standalone # CRDs first (User, ServiceAccount, LDAPConfig, OAuthConfig, OIDCConfig): helm install authn-crds oci://ghcr.io/krateo-platformops/charts/authn-crds --version 0.26.0 -# The JWT signing-key Secret the Deployment hard-requires (shared with snowplow): -kubectl create secret generic jwt-sign-key -n krateo-system \ - --from-literal=JWT_SIGN_KEY="$(openssl rand -hex 32)" +# The JWT signing-key Secret the Deployment hard-requires — a PEM-encoded RSA +# private key. authn signs asymmetrically (RS256) and publishes the public key +# at /.well-known/jwks.json for validators like snowplow (see docs/jwt-jwks.md): +openssl genrsa -out private.pem 2048 +kubectl create secret generic authn-jwt-signing-key -n krateo-system \ + --from-file=private.pem=./private.pem helm install authn oci://ghcr.io/krateo-platformops/charts/authn \ --version 0.26.0 --namespace krateo-system @@ -40,7 +43,7 @@ See [docs/configuration.md](docs/configuration.md). Most used: |---|---|---| | `env.AUTHN_KUBECONFIG_SERVER_URL` | `https://kube-apiserver:6443` | The apiserver URL written into every generated kubeconfig — set it to your cluster's reachable endpoint. | | `env.AUTHN_KUBECONFIG_CRT_EXPIRES_IN` | `24h` | Lifetime of the minted client certificate (and the login JWT). | -| `jwtSignKeySecretName` | `jwt-sign-key` | Secret holding `JWT_SIGN_KEY`; the pod does not start without it. | +| `jwt.signKeySecretName` / `jwt.signKeySecretKey` | `authn-jwt-signing-key` / `private.pem` | Secret holding the PEM-encoded RSA private key; the pod does not start without it. | ## Examples diff --git a/docs/api.md b/docs/api.md index 80c6d21..788d755 100644 --- a/docs/api.md +++ b/docs/api.md @@ -57,7 +57,7 @@ Every strategy returns the same shape: ```json { - "accessToken": "", + "accessToken": "", "user": { "displayName": "…", "username": "…", "avatarURL": "…" }, "groups": ["…"], "data": { "kind": "Config", "…": "the per-user kubeconfig" } diff --git a/docs/architecture.md b/docs/architecture.md index 25fb5ca..327bb79 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,8 +30,8 @@ API. (`main.go:59-60`), apiserver URL for the generated kubeconfig (`main.go:61-62`), the snowplow URL for RESTAction calls (`main.go:63-72`), the storage namespace (`AUTHN_NAMESPACE`, `main.go:73-74`), the authn service username (`AUTHN_USERNAME`, - default `authn`, `main.go:75-76`), the JWT signing key (`JWT_SIGN_KEY`, - `main.go:77`), and the ServiceAccount-token audience + default `authn`, `main.go:75-76`), the JWT signing-key file and key ID + (`JWT_SIGN_KEY_FILE` / `JWT_KID`), and the ServiceAccount-token audience (`AUTHN_SERVICEACCOUNT_AUDIENCE`, default `authn`, `main.go:78-80`). 2. **Logger.** zerolog to stdout, `info` unless `--debug` (`main.go:94-105`). 3. **OpenTelemetry (default OFF).** `telemetry.Setup` initializes the gated @@ -147,8 +147,10 @@ is enforced later by the apiserver, not by authn. ## The response encoder + JWT — `internal/helpers/encode` `encode.Success` (`success.go:18-53`) wraps the kubeconfig bytes in -`{accessToken,user,groups,data}` and, when a `JwtSingKey` is configured, mints a JWT -via `plumbing/jwtutil` (default 8h if no duration, `success.go:32-46`). +`{accessToken,user,groups,data}` and, when a `JwtPrivateKey` is configured, mints an +RS256 JWT (with `kid` header) via `plumbing/jwtutil` (default 8h if no duration, +`success.go:32-46`). The matching public key is served at `/.well-known/jwks.json` +by the `jwks` route ([jwt-jwks](./jwt-jwks.md)). `encode.Attach` (`attach.go`) instead streams the kubeconfig as a file download when the basic route is called with `?d` (`basic/login.go:94-96`). diff --git a/docs/behavior.md b/docs/behavior.md index 9464d75..1a73845 100644 --- a/docs/behavior.md +++ b/docs/behavior.md @@ -23,6 +23,7 @@ Every route is registered in `main.go:169-233` and implements `routes.Route`. | GET | `/strategies` | List configured login strategies (for the frontend login page) | `internal/routes/auth/strategies/strategies.go` | | GET | `/info` | Fetch a stored `AuthInfo` by `?name=` | `internal/routes/auth/info/info.go` | | GET | `/health` | Liveness/readiness; returns `{name,version}` once healthy, else `503` | `internal/routes/health/health.go` | +| GET | `/.well-known/jwks.json` | Public key set for verifying authn's RS256 JWTs ([jwt-jwks](./jwt-jwks.md)) | `internal/routes/jwks/jwks.go` | | GET | `/basic/login` | HTTP Basic login → kubeconfig (+JWT) | `internal/routes/auth/basic/login.go` | | POST | `/serviceaccount/login` | Kubernetes intra-service auth: SA token (TokenReview) → kubeconfig (+JWT) | `internal/routes/auth/serviceaccount/login.go` | | POST | `/ldap/login` | LDAP login (JSON body) → kubeconfig (+JWT) | `internal/routes/auth/ldap/login.go` | @@ -58,7 +59,7 @@ return: ``` - `data` is the per-user kubeconfig minted by the generator (`config/build.go:115-147`). -- `accessToken` is present only when `JWT_SIGN_KEY` is set; the JWT duration is the +- `accessToken` is always present — authn fails to boot without a valid signing key; the JWT duration is the cert-duration knob, with an 8h default if no explicit duration (`success.go:32-46`). - `/basic/login?d` instead returns the kubeconfig as a file download diff --git a/docs/configuration.md b/docs/configuration.md index ff9de99..970b346 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -25,8 +25,10 @@ goes through `envFrom` ([`configmap.yaml`](../helm/authn/templates/configmap.yaml)) — `AUTHN_PORT` (from `service.port`), `AUTHN_NAMESPACE` + `POD_NAMESPACE` (the release namespace), plus everything under `.Values.env`; -2. the JWT signing-key Secret — `jwtSignKeySecretName` (default `jwt-sign-key`, key - `JWT_SIGN_KEY`); the pod does not start without it. +2. the JWT signing-key Secret — `jwt.signKeySecretName` (default `authn-jwt-signing-key`, key + `jwt.signKeySecretKey` / `private.pem`), mounted as a **file** (not injected as an + env value) and read via `JWT_SIGN_KEY_FILE`; the pod does not start without it. See + [jwt-jwks](./jwt-jwks.md). A `checksum/configmap` pod annotation rolls the Deployment when the ConfigMap changes. Every flag of the binary has an env fallback (`go/authn/main.go:49-80`), so @@ -42,7 +44,8 @@ changes. Every flag of the binary has an env fallback (`go/authn/main.go:49-80`) | `replicaCount` | `1` | With `autoscaling.enabled: false` (default). | | `livenessProbe` / `readinessProbe` | `GET /health` | `/health` flips 200 once the listener goroutine starts, 503 on shutdown — process lifecycle only ([gotchas](./gotchas.md)). | | `ingress` | `enabled: false` | Standard chart ingress if you need it. | -| `jwtSignKeySecretName` | `jwt-sign-key` | Secret holding `JWT_SIGN_KEY` (shared platform-wide with the JWT validators). | +| `jwt.signKeySecretName` / `jwt.signKeySecretKey` | `authn-jwt-signing-key` / `private.pem` | Secret holding the PEM-encoded RSA private key, mounted as a file. | +| `jwt.kid` | `krateo-authn-key-1` | Key ID (`kid`) stamped into every token header and the JWKS ([jwt-jwks](./jwt-jwks.md)). | | `serviceAccount.create` | `true` | The SA the CSR/TokenReview ClusterRoles bind to. | | `env.*` | see below | Rendered into the ConfigMap. | @@ -64,16 +67,17 @@ Chart defaults first, then the env vars the binary reads beyond what the chart s | `POD_NAMESPACE` | chart: release namespace | The operator namespace — **all config CRs are resolved here**. | | `AUTHN_USERNAME` | `authn` | The service's own identity for calling snowplow RESTActions. | | `AUTHN_SERVICEACCOUNT_AUDIENCE` | `authn` | Audience the projected SA token must carry for `/serviceaccount/login`. | -| `JWT_SIGN_KEY` | (from the Secret) | JWT signing key; without it no `accessToken` is issued ([gotchas](./gotchas.md)). | +| `JWT_SIGN_KEY_FILE` | (mounted from the Secret) | Path to the PEM-encoded RSA private key; without a valid one authn exits at boot ([gotchas](./gotchas.md)). | +| `JWT_KID` | (from `jwt.kid`) | Key ID stamped into every token header and the JWKS; required, non-empty. | | `SNOWPLOW_SERVICE_HOST` / `SNOWPLOW_SERVICE_PORT` | unset / `8081` | Preferred snowplow endpoint source; beware the resolution quirk ([gotchas](./gotchas.md)). | | `URL_SNOWPLOW` | `http://snowplow.krateo-system.svc.cluster.local:8081` | Fallback snowplow URL when the SERVICE_HOST pair is unset. | | `OTEL_ENABLED` | `false` | Master OpenTelemetry gate; tracing and metrics each default to it. | | `OTEL_TRACING_ENABLED` / `OTEL_METRICS_ENABLED` | = `OTEL_ENABLED` | Per-signal overrides (`go/authn/internal/telemetry/telemetry.go:36-44`). | | `OTEL_EXPORTER_OTLP_ENDPOINT` | (SDK standard) | OTLP/HTTP collector endpoint when telemetry is on. | -Flags mirror all of the above (`--port`, `--cert-expires`, `--jwt-sign-key`, -`--serviceaccount-audience`, `--otel-tracing`, `--kubeconfig` for out-of-cluster -runs, …) — flags win over env ([architecture](./architecture.md)). +Flags mirror all of the above (`--port`, `--cert-expires`, `--jwt-sign-key-file`, +`--jwt-kid`, `--serviceaccount-audience`, `--otel-tracing`, `--kubeconfig` for +out-of-cluster runs, …) — flags win over env ([architecture](./architecture.md)). ## The CRDs chart diff --git a/docs/gotchas.md b/docs/gotchas.md index 425d2cb..af53843 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -118,12 +118,13 @@ flags are parsed, so when those env vars are unset it produces the literal other than `8081`, the fallback check (`== "http://:8081"`) misses and authn calls a hostless URL. Set `URL_SNOWPLOW` explicitly to be safe. -## No JWT without a signing key -If `JWT_SIGN_KEY` is empty, `encode.Success` omits `accessToken` entirely -(`encode/success.go:32`) and the service-identity token used to call snowplow is -signed with an empty key (`main.go:195-203`). Clients expecting a bearer token, and -RESTAction enrichment, both depend on `JWT_SIGN_KEY` being set. The chart makes this -structural: the Deployment `envFrom`-requires the `jwt-sign-key` Secret. +## No boot without a signing key +authn signs asymmetrically with RS256 ([jwt-jwks](./jwt-jwks.md)) and fails fast at +startup — not per-request — if `JWT_KID` is empty or `JWT_SIGN_KEY_FILE` is +missing/unparseable (`main.go`): `log.Fatal` before any route is registered. The +chart makes the dependency structural: the Deployment mounts the `authn-jwt-signing-key` +Secret's PEM private key as a file. Rotating the key without changing `kid` (or vice +versa) makes previously issued tokens unverifiable against the new JWKS. ## Health gates on a flag flipped after listen `/health` returns `503` until the goroutine sets `healthy=1` (`main.go:302`), and diff --git a/docs/index.md b/docs/index.md index 8c33057..1ecb989 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,6 +26,8 @@ and one version line: image and charts ship together from a single plain-semver - [configuration](./configuration.md) — the whole config surface: values, the env ConfigMap contract, flags, OTel gates. - [api](./api.md) — the five `*.authn.krateo.io` CRDs and the HTTP surface. +- [jwt-jwks](./jwt-jwks.md) — RS256 signing, the JWKS endpoint, and how Snowplow / + agentgateway consume it. - [rbac](./rbac.md) — how the generated client cert (CN=username, O=groups) maps to Kubernetes RBAC: binding roles to `User` and `Group` subjects. authn issues identity; RBAC authorizes it. diff --git a/docs/jwt-jwks.md b/docs/jwt-jwks.md new file mode 100644 index 0000000..aa51e35 --- /dev/null +++ b/docs/jwt-jwks.md @@ -0,0 +1,192 @@ +--- +type: Integration +title: authn — JWT signing & JWKS +description: How authn signs JWTs with RS256, publishes a JWKS, and how validators (Snowplow, agentgateway) consume it. +tags: [authn, jwt, jwks, rs256, security] +timestamp: 2026-08-10T00:00:00Z +--- + +# authn JWT signing & JWKS + +authn issues JSON Web Tokens for the Krateo platform. It signs with **RS256** using +an RSA private key and publishes the matching public key as a **JWKS** so that any +validator — Snowplow, agentgateway, or a third party — can verify tokens without +sharing a secret. + +## What a token looks like + +- **Algorithm:** `RS256` (asymmetric). Symmetric `HS256` is no longer used. +- **Header:** carries a `kid` identifying the signing key (required by agentgateway). +- **Claims:** `username`, `groups`, `iss: "krateo.io"`, `sub`, `exp`, `iat`, `nbf`. + No `aud`. + +The signing itself lives in the shared `plumbing/jwtutil` library +(`CreateToken` / `Validate`); authn only supplies the key material and `kid`. + +## Configuration + +authn reads two settings at startup: + +| Flag | Env | Meaning | +| --- | --- | --- | +| `--jwt-sign-key-file` | `JWT_SIGN_KEY_FILE` | Path to the PEM-encoded RSA **private** key. | +| `--jwt-kid` | `JWT_KID` | Key ID stamped into every token header and the JWKS. | + +Both are required; authn exits at boot if the key file is missing/unparseable or +the `kid` is empty. The private key is read from a **file** (mounted from a Secret), +never passed as a raw env value. See [configuration](./configuration.md) for the +full Helm values (`jwt.signKeySecretName`, `jwt.signKeySecretKey`, `jwt.mountPath`, +`jwt.kid`). + +## Creating the signing-key Secret + +Generate an RSA keypair and store the private key in a Secret. authn derives the +public key (and the JWKS) from it at runtime — you only ever store the private half. + +```sh +# 1. Generate a 2048-bit RSA private key. +openssl genrsa -out private.pem 2048 + +# 2. Create the Secret in authn's namespace. +kubectl create secret generic authn-jwt-signing-key \ + --namespace krateo-system \ + --from-file=private.pem=./private.pem +``` + +The Helm chart mounts this Secret at `/etc/authn/jwt/private.pem` and sets +`JWT_SIGN_KEY_FILE` accordingly. Relevant `values.yaml`: + +```yaml +jwt: + signKeySecretName: authn-jwt-signing-key # Secret name + signKeySecretKey: private.pem # key within the Secret + mounted filename + mountPath: /etc/authn/jwt # mount directory + kid: krateo-authn-key-1 # stable key ID (kid) +``` + +> Keep `kid` stable for the life of the key. Changing the key without changing the +> `kid` (or vice versa) makes previously issued tokens unverifiable. + +## The JWKS endpoint + +authn serves the public key set at: + +``` +GET /.well-known/jwks.json +``` + +on its normal service port (default `8082`) — no separate service or route change +is required. The response is a standard JWKS: + +```json +{ + "keys": [ + { + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": "krateo-authn-key-1", + "n": "", + "e": "AQAB" + } + ] +} +``` + +Quick check once deployed: + +```sh +kubectl -n krateo-system port-forward svc/authn 8082:8082 +curl -s http://localhost:8082/.well-known/jwks.json | jq +``` + +## Consuming the JWKS + +### agentgateway (remote JWKS) + +Point agentgateway at the endpoint via `jwks.remote` so it refetches on a cadence +(supports key rotation). The `issuer` matches authn's `iss`; omit `audiences` +because authn emits no `aud`. + +```yaml +apiVersion: agentgateway.dev/v1alpha1 +kind: AgentgatewayPolicy +metadata: + name: authn-jwt +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: mcp + traffic: + jwtAuthentication: + mode: Strict + providers: + - issuer: "krateo.io" + jwks: + remote: + backendRef: + name: authn # authn's Service + kind: Service + namespace: krateo-system + port: 8082 + jwksPath: /.well-known/jwks.json + cacheDuration: 5m +``` + +`groups`-based RBAC is enforced by a separate authorization policy over the decoded +claims. + +### Snowplow + +Snowplow validates the same tokens through the shared `plumbing/jwtutil` (via the +`server/use.UserConfig` middleware), which verifies **RS256 with a public key** +instead of a shared secret. It gets that key from **this endpoint** — there is no +public-key Secret and no key material mounted into Snowplow at all: + +| Flag | Env | Default | Meaning | +| --- | --- | --- | --- | +| `--jwks-url` | `JWT_JWKS_URL` | `""` → `/.well-known/jwks.json` | Where the key set is fetched from. | +| `--jwks-cache-ttl` | `JWT_JWKS_CACHE_TTL` | `5m` | How long a fetched key set is served before refresh. | +| `--jwks-min-refresh-interval` | `JWT_JWKS_MIN_REFRESH_INTERVAL` | `30s` | Floor between fetch attempts; throttles the refetch an unknown `kid` triggers. | +| `--jwks-request-timeout` | `JWT_JWKS_REQUEST_TIMEOUT` | `5s` | Per-fetch timeout. | + +Nothing to distribute: point Snowplow at authn (it already is, via `URL_AUTHN`) and +rotation follows automatically. The mechanics, implemented once in +`plumbing/jwtutil.JWKSKeySource` and reusable by any validator: + +- **Lazy fetch.** The key set is fetched on the first token validation, *not* at + startup — so a validator does not depend on authn being up first and does not crash + loop while authn restarts. +- **Cached.** Within the TTL, validation is pure local RSA verification; authn is off + the per-request path. +- **Rotation-aware.** A token whose `kid` is not in the cache triggers a refetch + (rate-limited by the refresh floor, so an unknown `kid` cannot become a fetch per + request), which is what lets you rotate the keypair without redeploying validators. +- **Fail-soft.** If a refetch fails but the cache still holds the requested `kid`, the + stale-but-known key is served: a brief authn outage does not invalidate tokens that + were already verifiable. +- **Correct status codes.** An unresolvable key yields `503` (ours to fix, retryable), + never `401` — a `401` would tell a browser to discard a good session because authn + happened to be restarting. + +Publish both keys in the JWKS across a rotation (old + new `kid`) so tokens signed +before the switch stay verifiable until they expire. + +### The `serviceaccount` strategy + +The `/serviceaccount/login` route (Kubernetes intra-service auth) mints its JWT the +same way as every other login strategy — through `encode.Success` with the shared +`JwtPrivateKey`/`JwtKeyID` — so it needs no separate key configuration. + +## Migration notes (HS256 → RS256) + +- The old shared secret (`JWT_SIGN_KEY` / `AUTHN_JWT_SECRET`) is gone. Replace the + `authn-jwt-signing-key` Secret's contents with the PEM private key described above. +- **Version alignment:** authn, Snowplow, and any other validator must all build + against the `plumbing` version that carries the asymmetric `jwtutil`/`UserConfig` + API. A validator still on the HS256 build will reject the new RS256 tokens. +- Snowplow's `--jwt-sign-key` / `JWT_SIGN_KEY` is gone. It needs no key configuration + at all now: it reads the public key from this endpoint, derived from `URL_AUTHN` + unless `JWT_JWKS_URL` overrides it. Any `authn-jwt-public-key` Secret left over from + an earlier step of this migration is unused and can be deleted. diff --git a/docs/llms.txt b/docs/llms.txt index 577d4e0..dc86a31 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -7,6 +7,7 @@ docs/usage.md: install via the Krateo installer pin or direct helm install oci:/ docs/configuration.md: the whole config surface — values, env ConfigMap contract, flags, OTel gates docs/api.md: the five *.authn.krateo.io CRDs + the HTTP surface and login response contract docs/rbac.md: how the minted client cert (CN=username, O=groups) binds to Kubernetes RBAC — RoleBinding/ClusterRoleBinding with User/Group subjects; authn issues identity, never authors RBAC +docs/jwt-jwks.md: RS256 signing + the JWKS endpoint (/.well-known/jwks.json) — key config, rotation, and how Snowplow/agentgateway consume it docs/examples.md: index of the runnable examples docs/release.md: how a release ships — one plain-semver tag drives image + charts docs/log.md: curated history diff --git a/docs/overview.md b/docs/overview.md index e3292c6..5c04e14 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -12,8 +12,10 @@ timestamp: 2026-08-07T00:00:00Z authn is the **authentication service of the Krateo platform**: it converts a successful login into a Kubernetes identity. Whatever the strategy, the output is the same — a short-lived, per-user **client-certificate kubeconfig** minted through the -Kubernetes CSR API (cert `CN=username, O=groups`) plus a **JWT** signed with the -platform's shared `JWT_SIGN_KEY`. authn authenticates; it never authorizes — RBAC on +Kubernetes CSR API (cert `CN=username, O=groups`) plus a **JWT** signed +asymmetrically (RS256) with authn's own RSA private key; the matching public key is +published as a JWKS at `/.well-known/jwks.json` ([jwt-jwks](./jwt-jwks.md)). authn +authenticates; it never authorizes — RBAC on the minted identity is enforced by the apiserver via standard bindings on the cert's groups. @@ -61,7 +63,7 @@ controller, no cache and no state beyond the persisted `AuthInfo` Secrets. | Peer | Relationship | |---|---| | **frontend** | Consumes `GET /strategies` to render the login page and the login response (`data` + `accessToken`) to authenticate the user; CORS is on by default for the cross-origin browser hop. | -| **snowplow** | Two-way: authn calls snowplow's `/call` to resolve RESTActions that enrich OAuth2/OIDC identities (authenticating with its own self-minted service JWT), and snowplow validates the JWTs authn issues (shared `JWT_SIGN_KEY`). snowplow's prewarm seed is itself a `serviceaccount`-strategy consumer. | +| **snowplow** | Two-way: authn calls snowplow's `/call` to resolve RESTActions that enrich OAuth2/OIDC identities (authenticating with its own self-minted service JWT), and snowplow validates the JWTs authn issues using authn's RSA **public** key (RS256). snowplow's prewarm seed is itself a `serviceaccount`-strategy consumer. | | **core-provider / cdc** | Backend services use the `serviceaccount` strategy to obtain a scoped Krateo identity without holding the signing key — the exchange allowlist is the `ServiceAccount` CRD authn owns. | | **the cluster** | The CSR API is the identity mint (broad CSR RBAC required); the TokenReview API validates intra-service SA tokens ([gotchas](./gotchas.md)). | diff --git a/docs/usage.md b/docs/usage.md index 369fbfd..3cccb0b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -48,9 +48,11 @@ spec: # CRDs first: helm install authn-crds oci://ghcr.io/krateo-platformops/charts/authn-crds --version 0.26.0 -# The JWT signing-key Secret (hard dependency, see below): -kubectl create secret generic jwt-sign-key -n krateo-system \ - --from-literal=JWT_SIGN_KEY="$(openssl rand -hex 32)" +# The JWT signing-key Secret (hard dependency, see below) — a PEM-encoded RSA +# private key, not a shared secret: +openssl genrsa -out private.pem 2048 +kubectl create secret generic authn-jwt-signing-key -n krateo-system \ + --from-file=private.pem=./private.pem # The app chart: helm install authn oci://ghcr.io/krateo-platformops/charts/authn \ @@ -59,11 +61,11 @@ helm install authn oci://ghcr.io/krateo-platformops/charts/authn \ ### Install-time dependencies -- **The JWT signing-key Secret** — the Deployment `envFrom`-mounts the Secret named - by `jwtSignKeySecretName` (default `jwt-sign-key`, key `JWT_SIGN_KEY`); the pod - does not start without it. The same key must be shared with every service that - validates authn's JWTs (snowplow, sse-proxy) — the installer provisions one key - for all of them. +- **The JWT signing-key Secret** — the Deployment mounts, as a file, the Secret named + by `jwt.signKeySecretName` (default `authn-jwt-signing-key`, key `jwt.signKeySecretKey` / + `private.pem`); the pod does not start without it. authn signs asymmetrically + (RS256) and publishes the matching **public** key at `/.well-known/jwks.json` — see + [jwt-jwks](./jwt-jwks.md) for how validators (snowplow, agentgateway) consume it. - **CRDs before the app** — the app only *reads* the five `*.authn.krateo.io` CRDs at request time, so a missing CRD does not block startup, but every login of that strategy fails until its CRD (and a config CR) exists. Install `authn-crds` first. diff --git a/go/authn/go.mod b/go/authn/go.mod index 7e9efef..ea99e9d 100644 --- a/go/authn/go.mod +++ b/go/authn/go.mod @@ -8,7 +8,7 @@ require ( github.com/golang/gddo v0.0.0-20210115222349-20d68f94ee1f github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 - github.com/krateo-platformops/plumbing v1.13.0 + github.com/krateo-platformops/plumbing v1.14.0 github.com/rs/zerolog v1.32.0 github.com/stretchr/testify v1.11.1 golang.org/x/oauth2 v0.36.0 diff --git a/go/authn/go.sum b/go/authn/go.sum index 9c0f9c1..7a6c832 100644 --- a/go/authn/go.sum +++ b/go/authn/go.sum @@ -120,8 +120,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/krateo-platformops/plumbing v1.13.0 h1:h3ltdbLMMIuWYXq9UQzPauyYvWz+LuiQE9PX7mb9DbE= -github.com/krateo-platformops/plumbing v1.13.0/go.mod h1:FYJLKz/CWvDs6tnmsAV5GXRv8DGHKwiIYpN7gdRym2s= +github.com/krateo-platformops/plumbing v1.14.0 h1:Emgw9j4UdyxIe1V9IfjLREUqPOrRMvGgkqP6lMy+1KA= +github.com/krateo-platformops/plumbing v1.14.0/go.mod h1:FYJLKz/CWvDs6tnmsAV5GXRv8DGHKwiIYpN7gdRym2s= github.com/krateo-platformops/snowplow v1.8.0 h1:TxvhDnGCzvSU7uVlbH/lwjYcKelrpSzHi4vY+eU0WCQ= github.com/krateo-platformops/snowplow v1.8.0/go.mod h1:cs7cowdZBY13HNOYKk4Q6bz+OmxIj/+WS1PsAO3tSLk= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= diff --git a/go/authn/internal/helpers/encode/success.go b/go/authn/internal/helpers/encode/success.go index a6ce7a4..0ddb09a 100644 --- a/go/authn/internal/helpers/encode/success.go +++ b/go/authn/internal/helpers/encode/success.go @@ -1,6 +1,7 @@ package encode import ( + "crypto/rsa" "encoding/json" "net/http" "time" @@ -10,9 +11,10 @@ import ( ) type Extras struct { - UserInfo userinfo.Info - JwtDuration time.Duration - JwtSingKey string + UserInfo userinfo.Info + JwtDuration time.Duration + JwtPrivateKey *rsa.PrivateKey + JwtKeyID string } func Success(w http.ResponseWriter, dat []byte, extras *Extras) (err error) { @@ -29,7 +31,7 @@ func Success(w http.ResponseWriter, dat []byte, extras *Extras) (err error) { } out.Groups = nfo.GetGroups() - if extras.JwtSingKey != "" { + if extras.JwtPrivateKey != nil { if extras.JwtDuration <= 0 { extras.JwtDuration = time.Hour * 8 } @@ -38,7 +40,8 @@ func Success(w http.ResponseWriter, dat []byte, extras *Extras) (err error) { Username: nfo.GetUserName(), Groups: nfo.GetGroups(), Duration: extras.JwtDuration, - SigningKey: extras.JwtSingKey, + KeyID: extras.JwtKeyID, + PrivateKey: extras.JwtPrivateKey, }) if err != nil { return err diff --git a/go/authn/internal/routes/auth/basic/login.go b/go/authn/internal/routes/auth/basic/login.go index 2e10479..d000e1a 100644 --- a/go/authn/internal/routes/auth/basic/login.go +++ b/go/authn/internal/routes/auth/basic/login.go @@ -2,6 +2,7 @@ package basic import ( "context" + "crypto/rsa" "fmt" "net/http" "os" @@ -27,25 +28,28 @@ const ( type LoginOptions struct { KubeconfigGenerator kubeconfig.Generator JwtDuration time.Duration - JwtSingKey string + JwtPrivateKey *rsa.PrivateKey + JwtKeyID string } func Login(rc *rest.Config, opts LoginOptions) routes.Route { return &loginRoute{ - rc: rc, - gen: opts.KubeconfigGenerator, - jwtDuration: opts.JwtDuration, - jwtSignKey: opts.JwtSingKey, + rc: rc, + gen: opts.KubeconfigGenerator, + jwtDuration: opts.JwtDuration, + jwtPrivateKey: opts.JwtPrivateKey, + jwtKeyID: opts.JwtKeyID, } } var _ routes.Route = (*loginRoute)(nil) type loginRoute struct { - rc *rest.Config - gen kubeconfig.Generator - jwtDuration time.Duration - jwtSignKey string + rc *rest.Config + gen kubeconfig.Generator + jwtDuration time.Duration + jwtPrivateKey *rsa.PrivateKey + jwtKeyID string } func (r *loginRoute) Name() string { @@ -97,9 +101,10 @@ func (r *loginRoute) Handler() http.HandlerFunc { } encode.Success(wri, dat, &encode.Extras{ - UserInfo: user, - JwtDuration: r.jwtDuration, - JwtSingKey: r.jwtSignKey, + UserInfo: user, + JwtDuration: r.jwtDuration, + JwtPrivateKey: r.jwtPrivateKey, + JwtKeyID: r.jwtKeyID, }) } } diff --git a/go/authn/internal/routes/auth/ldap/login.go b/go/authn/internal/routes/auth/ldap/login.go index af12b71..3014762 100644 --- a/go/authn/internal/routes/auth/ldap/login.go +++ b/go/authn/internal/routes/auth/ldap/login.go @@ -1,6 +1,7 @@ package ldap import ( + "crypto/rsa" "errors" "fmt" "net/http" @@ -20,15 +21,17 @@ import ( type LoginOptions struct { KubeconfigGenerator kubeconfig.Generator JwtDuration time.Duration - JwtSingKey string + JwtPrivateKey *rsa.PrivateKey + JwtKeyID string } func Login(rc *rest.Config, opts LoginOptions) routes.Route { return &loginRoute{ - rc: rc, - gen: opts.KubeconfigGenerator, - jwtDuration: opts.JwtDuration, - jwtSignKey: opts.JwtSingKey, + rc: rc, + gen: opts.KubeconfigGenerator, + jwtDuration: opts.JwtDuration, + jwtPrivateKey: opts.JwtPrivateKey, + jwtKeyID: opts.JwtKeyID, } } @@ -43,10 +46,11 @@ var ( ) type loginRoute struct { - rc *rest.Config - gen kubeconfig.Generator - jwtDuration time.Duration - jwtSignKey string + rc *rest.Config + gen kubeconfig.Generator + jwtDuration time.Duration + jwtPrivateKey *rsa.PrivateKey + jwtKeyID string } func (r *loginRoute) Name() string { @@ -117,9 +121,10 @@ func (r *loginRoute) Handler() http.HandlerFunc { } encode.Success(wri, dat, &encode.Extras{ - UserInfo: nfo, - JwtDuration: r.jwtDuration, - JwtSingKey: r.jwtSignKey, + UserInfo: nfo, + JwtDuration: r.jwtDuration, + JwtPrivateKey: r.jwtPrivateKey, + JwtKeyID: r.jwtKeyID, }) } } diff --git a/go/authn/internal/routes/auth/oauth/login.go b/go/authn/internal/routes/auth/oauth/login.go index 2e09087..dea8af8 100644 --- a/go/authn/internal/routes/auth/oauth/login.go +++ b/go/authn/internal/routes/auth/oauth/login.go @@ -2,6 +2,7 @@ package oauth import ( "context" + "crypto/rsa" "fmt" "net/http" "os" @@ -25,15 +26,17 @@ import ( type LoginOptions struct { KubeconfigGenerator kubeconfig.Generator JwtDuration time.Duration - JwtSingKey string + JwtPrivateKey *rsa.PrivateKey + JwtKeyID string } func Login(ctx context.Context, rc *rest.Config, opts LoginOptions) routes.Route { return &loginRoute{ rc: rc, ctx: ctx, - gen: opts.KubeconfigGenerator, - jwtDuration: opts.JwtDuration, - jwtSignKey: opts.JwtSingKey, + gen: opts.KubeconfigGenerator, + jwtDuration: opts.JwtDuration, + jwtPrivateKey: opts.JwtPrivateKey, + jwtKeyID: opts.JwtKeyID, } } @@ -45,11 +48,12 @@ const ( var _ routes.Route = (*loginRoute)(nil) type loginRoute struct { - rc *rest.Config - gen kubeconfig.Generator - ctx context.Context - jwtDuration time.Duration - jwtSignKey string + rc *rest.Config + gen kubeconfig.Generator + ctx context.Context + jwtDuration time.Duration + jwtPrivateKey *rsa.PrivateKey + jwtKeyID string } func (r *loginRoute) Name() string { @@ -159,9 +163,10 @@ func (r *loginRoute) Handler() http.HandlerFunc { } encode.Success(wri, dat, &encode.Extras{ - UserInfo: user, - JwtDuration: r.jwtDuration, - JwtSingKey: r.jwtSignKey, + UserInfo: user, + JwtDuration: r.jwtDuration, + JwtPrivateKey: r.jwtPrivateKey, + JwtKeyID: r.jwtKeyID, }) } } diff --git a/go/authn/internal/routes/auth/oidc/login.go b/go/authn/internal/routes/auth/oidc/login.go index 4f09081..2013989 100644 --- a/go/authn/internal/routes/auth/oidc/login.go +++ b/go/authn/internal/routes/auth/oidc/login.go @@ -2,6 +2,7 @@ package oidc import ( "context" + "crypto/rsa" "fmt" "net/http" "os" @@ -28,26 +29,29 @@ const ( type LoginOptions struct { KubeconfigGenerator kubeconfig.Generator JwtDuration time.Duration - JwtSingKey string + JwtPrivateKey *rsa.PrivateKey + JwtKeyID string } func Login(ctx context.Context, rc *rest.Config, opts LoginOptions) routes.Route { return &loginRoute{ rc: rc, ctx: ctx, - gen: opts.KubeconfigGenerator, - jwtDuration: opts.JwtDuration, - jwtSignKey: opts.JwtSingKey, + gen: opts.KubeconfigGenerator, + jwtDuration: opts.JwtDuration, + jwtPrivateKey: opts.JwtPrivateKey, + jwtKeyID: opts.JwtKeyID, } } var _ routes.Route = (*loginRoute)(nil) type loginRoute struct { - rc *rest.Config - gen kubeconfig.Generator - ctx context.Context - jwtDuration time.Duration - jwtSignKey string + rc *rest.Config + gen kubeconfig.Generator + ctx context.Context + jwtDuration time.Duration + jwtPrivateKey *rsa.PrivateKey + jwtKeyID string } func (r *loginRoute) Name() string { @@ -144,9 +148,10 @@ func (r *loginRoute) Handler() http.HandlerFunc { } encode.Success(wri, dat, &encode.Extras{ - UserInfo: nfo, - JwtDuration: r.jwtDuration, - JwtSingKey: r.jwtSignKey, + UserInfo: nfo, + JwtDuration: r.jwtDuration, + JwtPrivateKey: r.jwtPrivateKey, + JwtKeyID: r.jwtKeyID, }) } } diff --git a/go/authn/internal/routes/auth/serviceaccount/login.go b/go/authn/internal/routes/auth/serviceaccount/login.go index dd82784..e3f2963 100644 --- a/go/authn/internal/routes/auth/serviceaccount/login.go +++ b/go/authn/internal/routes/auth/serviceaccount/login.go @@ -7,6 +7,7 @@ package serviceaccount import ( "context" + "crypto/rsa" "fmt" "net/http" "strings" @@ -34,7 +35,8 @@ const ( type LoginOptions struct { KubeconfigGenerator kubeconfig.Generator JwtDuration time.Duration - JwtSingKey string + JwtPrivateKey *rsa.PrivateKey + JwtKeyID string // Audience the projected SA token must carry (enforced via TokenReview); defaults to // DefaultAudience. Binding the audience prevents replay of tokens minted for the // apiserver or another service. @@ -47,22 +49,24 @@ func Login(rc *rest.Config, opts LoginOptions) routes.Route { aud = DefaultAudience } return &loginRoute{ - rc: rc, - gen: opts.KubeconfigGenerator, - jwtDuration: opts.JwtDuration, - jwtSignKey: opts.JwtSingKey, - audience: aud, + rc: rc, + gen: opts.KubeconfigGenerator, + jwtDuration: opts.JwtDuration, + jwtPrivateKey: opts.JwtPrivateKey, + jwtKeyID: opts.JwtKeyID, + audience: aud, } } var _ routes.Route = (*loginRoute)(nil) type loginRoute struct { - rc *rest.Config - gen kubeconfig.Generator - jwtDuration time.Duration - jwtSignKey string - audience string + rc *rest.Config + gen kubeconfig.Generator + jwtDuration time.Duration + jwtPrivateKey *rsa.PrivateKey + jwtKeyID string + audience string } func (r *loginRoute) Name() string { return "serviceaccount" } @@ -99,9 +103,10 @@ func (r *loginRoute) Handler() http.HandlerFunc { } encode.Success(wri, dat, &encode.Extras{ - UserInfo: user, - JwtDuration: r.jwtDuration, - JwtSingKey: r.jwtSignKey, + UserInfo: user, + JwtDuration: r.jwtDuration, + JwtPrivateKey: r.jwtPrivateKey, + JwtKeyID: r.jwtKeyID, }) } } diff --git a/go/authn/internal/routes/jwks/jwks.go b/go/authn/internal/routes/jwks/jwks.go new file mode 100644 index 0000000..928064d --- /dev/null +++ b/go/authn/internal/routes/jwks/jwks.go @@ -0,0 +1,79 @@ +package jwks + +import ( + "crypto/rsa" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + + "github.com/krateo-platformops/authn/internal/routes" +) + +// Path is the conventional discovery location agentgateway (and most JWKS +// consumers) fetch the key set from. +const Path = "/.well-known/jwks.json" + +// jwk is a single JSON Web Key describing the public half of authn's RSA +// signing key. Only the fields required to verify an RS256 signature are set. +type jwk struct { + Kty string `json:"kty"` + Use string `json:"use"` + Alg string `json:"alg"` + Kid string `json:"kid"` + N string `json:"n"` + E string `json:"e"` +} + +type jwkSet struct { + Keys []jwk `json:"keys"` +} + +// Endpoint serves the JWKS derived from the given RSA public key and key ID at +// GET /.well-known/jwks.json. Validators such as agentgateway select the key +// whose "kid" matches the token header, so kid must equal the KeyID authn signs +// with. The document is computed once at construction time and served verbatim. +func Endpoint(pub *rsa.PublicKey, kid string) routes.Route { + set := jwkSet{ + Keys: []jwk{ + { + Kty: "RSA", + Use: "sig", + Alg: "RS256", + Kid: kid, + N: base64.RawURLEncoding.EncodeToString(pub.N.Bytes()), + E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()), + }, + }, + } + + body, err := json.Marshal(set) + if err != nil { + // Marshalling a struct of strings cannot fail; guard anyway so a + // misconfiguration surfaces as an empty body rather than a panic. + body = []byte(`{"keys":[]}`) + } + + return &jwksRoute{body: body} +} + +var _ routes.Route = (*jwksRoute)(nil) + +type jwksRoute struct { + body []byte +} + +func (r *jwksRoute) Name() string { return "jwks" } + +func (r *jwksRoute) Pattern() string { return Path } + +func (r *jwksRoute) Method() string { return http.MethodGet } + +func (r *jwksRoute) Handler() http.HandlerFunc { + return func(wri http.ResponseWriter, _ *http.Request) { + wri.Header().Set("Content-Type", "application/json") + wri.Header().Set("Cache-Control", "public, max-age=300") + wri.WriteHeader(http.StatusOK) + wri.Write(r.body) + } +} diff --git a/go/authn/internal/routes/jwks/jwks_test.go b/go/authn/internal/routes/jwks/jwks_test.go new file mode 100644 index 0000000..6bd7773 --- /dev/null +++ b/go/authn/internal/routes/jwks/jwks_test.go @@ -0,0 +1,114 @@ +package jwks + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/krateo-platformops/plumbing/jwtutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestEndpointServesUsableKey is the important correctness check: a token +// signed by authn must validate against the public key reconstructed purely +// from the JWKS the endpoint serves. This exercises the n/e base64url encoding. +func TestEndpointServesUsableKey(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + const kid = "test-kid" + + // A token as authn would issue it. + token, err := jwtutil.CreateToken(jwtutil.CreateTokenOptions{ + Username: "alice", + Groups: []string{"admins"}, + Duration: time.Minute, + KeyID: kid, + PrivateKey: privateKey, + }) + require.NoError(t, err) + + // Fetch the JWKS from the endpoint. + route := Endpoint(&privateKey.PublicKey, kid) + req := httptest.NewRequest(http.MethodGet, Path, nil) + rec := httptest.NewRecorder() + route.Handler()(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + + var set jwkSet + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &set)) + require.Len(t, set.Keys, 1) + + k := set.Keys[0] + assert.Equal(t, "RSA", k.Kty) + assert.Equal(t, "sig", k.Use) + assert.Equal(t, "RS256", k.Alg) + assert.Equal(t, kid, k.Kid) + + // Reconstruct the public key from the JWKS n/e and validate the token. + nBytes, err := base64.RawURLEncoding.DecodeString(k.N) + require.NoError(t, err) + eBytes, err := base64.RawURLEncoding.DecodeString(k.E) + require.NoError(t, err) + + reconstructed := &rsa.PublicKey{ + N: new(big.Int).SetBytes(nBytes), + E: int(new(big.Int).SetBytes(eBytes).Int64()), + } + + info, err := jwtutil.Validate(reconstructed, token) + require.NoError(t, err) + assert.Equal(t, "alice", info.Username) + assert.ElementsMatch(t, []string{"admins"}, info.Groups) +} + +// TestEndpointIsConsumableByJWKSKeySource closes the cross-repo contract loop. +// The test above reconstructs the key by hand; this one drives authn's real +// endpoint through the SAME client its consumers use (plumbing's +// jwtutil.JWKSKeySource, which snowplow wires against this endpoint instead of +// mounting a public-key Secret). If the document authn serves and the parser +// consumers use ever disagree — field names, base64 variant, kid matching — this +// fails here rather than as a 503 in a cluster. +func TestEndpointIsConsumableByJWKSKeySource(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + const kid = "krateo-authn-key-1" + + // Serve authn's real JWKS route at its real path. + route := Endpoint(&privateKey.PublicKey, kid) + mux := http.NewServeMux() + mux.HandleFunc(route.Pattern(), route.Handler()) + srv := httptest.NewServer(mux) + defer srv.Close() + + // A consumer resolves the key exactly as snowplow does. + keys := jwtutil.NewJWKSKeySource(jwtutil.JWKSURL(srv.URL)) + + token, err := jwtutil.CreateToken(jwtutil.CreateTokenOptions{ + Username: "alice", + Groups: []string{"admins"}, + Duration: time.Minute, + KeyID: kid, + PrivateKey: privateKey, + }) + require.NoError(t, err) + + info, err := jwtutil.ValidateWithKeySource(keys, token) + require.NoError(t, err, "a token authn signed must verify against the JWKS authn publishes") + assert.Equal(t, "alice", info.Username) + assert.ElementsMatch(t, []string{"admins"}, info.Groups) + + // authn's advertised path is the one the client derives from a base URL. + assert.Equal(t, Path, jwtutil.DefaultJWKSPath, + "authn's JWKS path must match the path consumers derive from authn's base URL") +} diff --git a/go/authn/main.go b/go/authn/main.go index 2dae68f..dfc1abf 100644 --- a/go/authn/main.go +++ b/go/authn/main.go @@ -25,6 +25,7 @@ import ( "github.com/krateo-platformops/authn/internal/routes/auth/serviceaccount" "github.com/krateo-platformops/authn/internal/routes/auth/strategies" "github.com/krateo-platformops/authn/internal/routes/health" + "github.com/krateo-platformops/authn/internal/routes/jwks" "github.com/krateo-platformops/authn/internal/telemetry" xcontext "github.com/krateo-platformops/plumbing/context" "github.com/krateo-platformops/plumbing/jwtutil" @@ -74,7 +75,8 @@ func main() { env.String("AUTHN_NAMESPACE", ""), "namespace where to store secrets with generated config") authnUsername := flag.String("authn-username", env.String("AUTHN_USERNAME", "authn"), "authn username for clientconfig for restaction api calls") - signKey := flag.String("jwt-sign-key", env.String("JWT_SIGN_KEY", ""), "secret key used to sign JWT tokens") + signKeyFile := flag.String("jwt-sign-key-file", env.String("JWT_SIGN_KEY_FILE", ""), "path to the PEM-encoded RSA private key used to sign JWT tokens (mounted from a Secret)") + jwtKeyID := flag.String("jwt-kid", env.String("JWT_KID", ""), "key ID (kid) set in the JWT header; must match the kid published in the JWKS") serviceAccountAudience := flag.String("serviceaccount-audience", env.String("AUTHN_SERVICEACCOUNT_AUDIENCE", serviceaccount.DefaultAudience), "audience the projected ServiceAccount token must carry for the /serviceaccount/login strategy") @@ -164,17 +166,33 @@ func main() { kubeconfig.Log(log), ) + // JWT signing key: an RSA private key, PEM-encoded, mounted from a Secret. + // The matching public key must be published in the JWKS under *jwtKeyID. + if *jwtKeyID == "" { + log.Fatal().Msg("JWT key ID must be set (--jwt-kid / JWT_KID)") + } + pemBytes, err := os.ReadFile(*signKeyFile) + if err != nil { + log.Fatal().Err(err).Msg("reading JWT signing key file") + } + privateKey, err := jwtutil.ParseRSAPrivateKeyFromPEM(pemBytes) + if err != nil { + log.Fatal().Err(err).Msg("parsing JWT signing key") + } + healthy := int32(0) all := []routes.Route{} all = append(all, strategies.List(cfg)) all = append(all, info.Info(cfg)) all = append(all, health.Check(&healthy, Version, serviceName)) + all = append(all, jwks.Endpoint(&privateKey.PublicKey, *jwtKeyID)) all = append(all, basic.Login(cfg, basic.LoginOptions{ KubeconfigGenerator: gen, JwtDuration: *certExpiresIn, - JwtSingKey: *signKey, + JwtPrivateKey: privateKey, + JwtKeyID: *jwtKeyID, })) // Kubernetes intra-service auth: backend services exchange their own (audience-bound) @@ -182,20 +200,23 @@ func main() { all = append(all, serviceaccount.Login(cfg, serviceaccount.LoginOptions{ KubeconfigGenerator: gen, JwtDuration: *certExpiresIn, - JwtSingKey: *signKey, + JwtPrivateKey: privateKey, + JwtKeyID: *jwtKeyID, Audience: *serviceAccountAudience, })) all = append(all, ldap.Login(cfg, ldap.LoginOptions{ KubeconfigGenerator: gen, JwtDuration: *certExpiresIn, - JwtSingKey: *signKey, + JwtPrivateKey: privateKey, + JwtKeyID: *jwtKeyID, })) accessToken, err := jwtutil.CreateToken(jwtutil.CreateTokenOptions{ Username: *authnUsername, Groups: []string{"authn"}, - SigningKey: *signKey, + KeyID: *jwtKeyID, + PrivateKey: privateKey, Duration: time.Hour * 8760, // 1 year, }) if err != nil { @@ -214,7 +235,8 @@ func main() { ), cfg, oauth.LoginOptions{ KubeconfigGenerator: gen, JwtDuration: *certExpiresIn, - JwtSingKey: *signKey, + JwtPrivateKey: privateKey, + JwtKeyID: *jwtKeyID, })) all = append(all, oidc.Login( @@ -229,7 +251,8 @@ func main() { ), cfg, oidc.LoginOptions{ KubeconfigGenerator: gen, JwtDuration: *certExpiresIn, - JwtSingKey: *signKey, + JwtPrivateKey: privateKey, + JwtKeyID: *jwtKeyID, })) var handler http.Handler = routes.Serve(all, log) diff --git a/go/authn/manifests/deploy.local.yaml b/go/authn/manifests/deploy.local.yaml index 8f42a14..18e7430 100644 --- a/go/authn/manifests/deploy.local.yaml +++ b/go/authn/manifests/deploy.local.yaml @@ -73,6 +73,17 @@ spec: app: authn spec: serviceAccountName: authn + volumes: + # The RSA private key authn signs RS256 JWTs with. Create it first: + # openssl genrsa -out private.pem 2048 + # kubectl create secret generic authn-jwt-signing-key -n demo-system \ + # --from-file=private.pem=./private.pem + - name: jwt-signing-key + secret: + secretName: authn-jwt-signing-key + items: + - key: private.pem + path: private.pem containers: - name: authn #image: kind.local/authn:latest @@ -82,7 +93,12 @@ spec: - --debug=true - --kubeconfig-server-url=https://127.0.0.1:57456 - --namespace=demo-system - - --jwt-sign-key=AbbraCadabbra + - --jwt-sign-key-file=/etc/authn/jwt/private.pem + - --jwt-kid=krateo-authn-key-1 ports: - name: http containerPort: 8082 + volumeMounts: + - name: jwt-signing-key + mountPath: /etc/authn/jwt + readOnly: true diff --git a/go/authn/testdata/oauth.yaml b/go/authn/testdata/oauth.yaml index 9ee334b..5775e07 100644 --- a/go/authn/testdata/oauth.yaml +++ b/go/authn/testdata/oauth.yaml @@ -48,6 +48,8 @@ spec: { "name": .userInfo.login, "email": .userInfo.email, "preferredUsername": .userInfo.login, "avatarURL": .userInfo.avatar_url } - name: groups verb: POST + dependsOn: + name: userInfo headers: - 'Content-Type: application/json' - "${ \"Authorization: Bearer \" + .token }" diff --git a/helm/authn/templates/deployment.yaml b/helm/authn/templates/deployment.yaml index 4adf68a..b629f96 100644 --- a/helm/authn/templates/deployment.yaml +++ b/helm/authn/templates/deployment.yaml @@ -36,8 +36,11 @@ spec: envFrom: - configMapRef: name: {{ include "authn.fullname" . }} - - secretRef: - name: {{ .Values.jwtSignKeySecretName }} + env: + - name: JWT_SIGN_KEY_FILE + value: "{{ .Values.jwt.mountPath }}/{{ .Values.jwt.signKeySecretKey }}" + - name: JWT_KID + value: {{ .Values.jwt.kid | quote }} securityContext: {{- toYaml .Values.securityContext | nindent 12 }} image: {{ include "krateo.image" (dict "img" .Values.image "global" .Values.global "defaultTag" .Chart.AppVersion) | quote }} @@ -52,14 +55,23 @@ spec: {{- toYaml .Values.readinessProbe | nindent 12 }} resources: {{- toYaml .Values.resources | nindent 12 }} - {{- with .Values.volumeMounts }} volumeMounts: + - name: jwt-signing-key + mountPath: {{ .Values.jwt.mountPath }} + readOnly: true + {{- with .Values.volumeMounts }} {{- toYaml . | nindent 12 }} - {{- end }} - {{- with .Values.volumes }} + {{- end }} volumes: + - name: jwt-signing-key + secret: + secretName: {{ .Values.jwt.signKeySecretName }} + items: + - key: {{ .Values.jwt.signKeySecretKey }} + path: {{ .Values.jwt.signKeySecretKey }} + {{- with .Values.volumes }} {{- toYaml . | nindent 8 }} - {{- end }} + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/helm/authn/values.schema.json b/helm/authn/values.schema.json index 77bdf6a..0afce14 100644 --- a/helm/authn/values.schema.json +++ b/helm/authn/values.schema.json @@ -296,10 +296,33 @@ "type": "string" } }, - "jwtSignKeySecretName": { - "type": "string", - "title": "JWT sign key secret name", - "description": "Name of the Secret holding the JWT signing key (created by the installer umbrella before authn)." + "jwt": { + "type": "object", + "title": "JWT signing key", + "description": "authn signs tokens with an RSA private key (RS256) and publishes the matching public key as a JWKS at GET /.well-known/jwks.json. The private key is mounted from a Secret as a file (never injected as an env var).", + "additionalProperties": false, + "properties": { + "signKeySecretName": { + "type": "string", + "title": "JWT sign key secret name", + "description": "Name of the Secret holding the PEM-encoded RSA private key (created by the installer umbrella before authn)." + }, + "signKeySecretKey": { + "type": "string", + "title": "JWT sign key secret key", + "description": "Key inside that Secret whose value is the PEM private key; it is also the mounted filename." + }, + "mountPath": { + "type": "string", + "title": "JWT signing key mount path", + "description": "Directory the Secret is mounted into. The container reads / (exposed as JWT_SIGN_KEY_FILE)." + }, + "kid": { + "type": "string", + "title": "JWT key ID", + "description": "Key ID advertised in every token header and in the JWKS \"kid\". Must be stable for the lifetime of the key so validators can match it." + } + } }, "global": { "type": "object", diff --git a/helm/authn/values.yaml b/helm/authn/values.yaml index eeea3ea..f051e1f 100644 --- a/helm/authn/values.yaml +++ b/helm/authn/values.yaml @@ -135,4 +135,19 @@ env: AUTHN_CORS: "true" AUTHN_DUMP_ENV: "true" -jwtSignKeySecretName: jwt-sign-key \ No newline at end of file +# JWT signing key configuration. +# authn signs tokens with an RSA private key (RS256) and publishes the matching +# public key as a JWKS at GET /.well-known/jwks.json. The private key is mounted +# from a Secret as a file (never injected as an env var). +jwt: + # Name of the Secret holding the PEM-encoded RSA private key. + signKeySecretName: authn-jwt-signing-key + # Key inside that Secret whose value is the PEM private key; it is also the + # mounted filename. + signKeySecretKey: private.pem + # Directory the Secret is mounted into. The container reads + # / (exposed as JWT_SIGN_KEY_FILE). + mountPath: /etc/authn/jwt + # Key ID advertised in every token header and in the JWKS "kid". Must be + # stable for the lifetime of the key so validators can match it. + kid: krateo-authn-key-1 \ No newline at end of file