Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ Every strategy returns the same shape:

```json
{
"accessToken": "<JWT — omitted when JWT_SIGN_KEY is unset>",
"accessToken": "<RS256 JWT, kid header set — see jwt-jwks.md>",
"user": { "displayName": "…", "username": "…", "avatarURL": "…" },
"groups": ["…"],
"data": { "kind": "Config", "…": "the per-user kubeconfig" }
Expand Down
10 changes: 6 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`).

Expand Down
3 changes: 2 additions & 1 deletion docs/behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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
Expand Down
18 changes: 11 additions & 7 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. |

Expand All @@ -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

Expand Down
13 changes: 7 additions & 6 deletions docs/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
192 changes: 192 additions & 0 deletions docs/jwt-jwks.md
Original file line number Diff line number Diff line change
@@ -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": "<base64url-modulus>",
"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` | `""` → `<URL_AUTHN>/.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.
1 change: 1 addition & 0 deletions docs/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading