diff --git a/chart/files/component-pins.yaml b/chart/files/component-pins.yaml index 969626d..7406286 100644 --- a/chart/files/component-pins.yaml +++ b/chart/files/component-pins.yaml @@ -2,7 +2,7 @@ components: - name: authn-crd kind: AuthnCrds chart: authn-crds - version: 0.26.2 + version: 0.27.0 tier: platform feature: portal - name: oasgen-provider-crd @@ -20,13 +20,13 @@ components: - name: snowplow-crd kind: SnowplowCrds chart: snowplow-crds - version: 1.9.3 + version: 1.10.0 tier: platform feature: portal - name: authn kind: Authn chart: authn - version: 0.26.2 + version: 0.27.0 deps: - authn-crd tier: platform @@ -40,7 +40,7 @@ components: - name: snowplow kind: Snowplow chart: snowplow - version: 1.9.3 + version: 1.10.0 deps: - snowplow-crd - authn diff --git a/chart/templates/secret.yaml b/chart/templates/secret.yaml index e244552..5cb4a6a 100644 --- a/chart/templates/secret.yaml +++ b/chart/templates/secret.yaml @@ -1,21 +1,29 @@ {{/* - JWT signing key — replaces the installer's `create-jwt-sign-key` object step. - `lookup` reuses an existing key on re-render so reconciles never rotate it; - only generated on first create. + authn signs JWTs asymmetrically (RS256) with the RSA PRIVATE key in Secret + `authn-jwt-signing-key`, key `private.pem` (matching authn chart defaults + jwt.signKeySecretName/signKeySecretKey). + + ONLY the private key is created here. Consumers that verify authn's tokens + (snowplow et al.) read the PUBLIC key from authn's JWKS endpoint, + GET /.well-known/jwks.json, which authn derives from this private key at + startup. + + `lookup` reuses an existing key on re-render so reconciles never rotate it; a + fresh key is generated only on first create. */}} {{- if and ((include "inst.featureEnabled" (list $ "portal")) | trim) (not .Values.bootstrap.coreProvider.enabled) }} {{- $ns := .Values.namespaces.krateo }} -{{- $existing := lookup "v1" "Secret" $ns "jwt-sign-key" }} -{{- $key := "" }} -{{- if and $existing $existing.data }} -{{- $key = index $existing.data "JWT_SIGN_KEY" | b64dec }} +{{- $existingPriv := lookup "v1" "Secret" $ns "authn-jwt-signing-key" }} +{{- $privPEM := "" }} +{{- if and $existingPriv $existingPriv.data (hasKey $existingPriv.data "private.pem") }} +{{- $privPEM = index $existingPriv.data "private.pem" | b64dec }} {{- else }} -{{- $key = randAlphaNum 12 }} +{{- $privPEM = genPrivateKey "rsa" }} {{- end }} apiVersion: v1 kind: Secret metadata: - name: jwt-sign-key + name: authn-jwt-signing-key namespace: {{ $ns }} # NB: do NOT set app.kubernetes.io/managed-by here. Helm forces this label to # "Helm" on every release resource at apply time; a chart-set value (e.g. @@ -24,5 +32,6 @@ metadata: # helm's live value instead of fighting it. type: Opaque stringData: - JWT_SIGN_KEY: {{ $key | quote }} -{{- end }} \ No newline at end of file + private.pem: | +{{ $privPEM | indent 4 }} +{{- end }} diff --git a/chart/values.schema.json b/chart/values.schema.json index 1de28bd..5e0670b 100644 --- a/chart/values.schema.json +++ b/chart/values.schema.json @@ -599,10 +599,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", @@ -4485,7 +4508,7 @@ } } }, - "description": "Additional envFrom entries appended to the chart-managed ConfigMap and jwt-sign-key Secret.", + "description": "Additional envFrom entries appended to the chart-managed ConfigMap.", "title": "Extra Env From" }, "initContainers": { @@ -4583,10 +4606,33 @@ "description": "Env vars rendered into the chart's snowplow ConfigMap and consumed via envFrom.", "title": "Env" }, - "jwtSignKeySecretName": { - "type": "string", - "description": "Name of the Secret holding the JWT signing key.", - "title": "JWT Sign Key Secret Name" + "jwt": { + "type": "object", + "additionalProperties": false, + "description": "snowplow verifies RS256 JWTs issued by authn using authn's RSA public key, fetched from authn's JWKS endpoint (/.well-known/jwks.json) and cached. No key material is mounted: snowplow holds neither a private/signing key nor a copy of the public key, so rotating authn's keypair needs no snowplow redeploy. The key set is fetched lazily on first validation, not at startup, so snowplow does not depend on authn being up first.", + "title": "JWT verification keys (JWKS)", + "properties": { + "jwksUrl": { + "type": "string", + "description": "Full JWKS URL. Empty derives it from URL_AUTHN as /.well-known/jwks.json; set only to point at a different authn.", + "title": "JWKS URL" + }, + "cacheTTL": { + "type": "string", + "description": "How long a fetched key set is served before it is refreshed (Go duration, e.g. \"5m\").", + "title": "JWKS cache TTL" + }, + "minRefreshInterval": { + "type": "string", + "description": "Minimum gap between two JWKS fetch attempts (Go duration, e.g. \"30s\"). Throttles the refetch an unknown \"kid\" triggers so it cannot become one fetch per request.", + "title": "JWKS minimum refresh interval" + }, + "requestTimeout": { + "type": "string", + "description": "Timeout for a single JWKS fetch (Go duration, e.g. \"5s\"). Also bounds how long a token validation can block.", + "title": "JWKS request timeout" + } + } }, "global": { "type": "object", diff --git a/docs/overview.md b/docs/overview.md index 69e5911..d9f477c 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -89,9 +89,14 @@ Pass B also computes the platform wiring at render time (no post-install patchin exposure `service.type`/`port` flips, browser-reachable peer URLs for the frontend config (`inst.peerurl` / `inst.lbip` / `inst.nodeip`), Vertex/local-model injection, the HITL gate and the autopilot's auto-derived `extraAgents` fleet — the whole surface -is described in [configuration](./configuration.md). `secret.yaml` generates the -`jwt-sign-key` Secret once and reuses it via `lookup` on every later render (reconciles -never rotate it). +is described in [configuration](./configuration.md). `secret.yaml` generates an RSA +private key once and reuses it via `lookup` on every later render (reconciles never +rotate it); it lands in the `authn-jwt-signing-key` Secret (`private.pem`) for authn to +sign RS256 JWTs with. Only the private key is created: verifiers such as snowplow read +the **public** key from authn's JWKS endpoint (`/.well-known/jwks.json`), which authn +derives from this private key at startup. A second Secret holding the public key would +be one more object to keep in sync, and would pin verifiers to a keypair only a +redeploy of all of them could rotate. The **self-reconcile loop**: the `installer` CompositionDefinition points at this chart, so the Installer CR *is* a composition like any other. The cdc re-renders it on diff --git a/docs/usage.md b/docs/usage.md index f3453f9..148c507 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -146,8 +146,9 @@ helm template installer chart/ --namespace krateo-system \ ``` Client-side `helm template` has no live cluster to `lookup`, so composition-mode -renders emit Pass A (and the `jwt-sign-key` Secret) but no Pass B Compositions — every -Pass B gate reads "CRD not served yet". That is the gating working as designed, not a +renders emit Pass A (and the `authn-jwt-signing-key` Secret) +but no Pass B Compositions — every Pass B gate reads "CRD not served yet". That is the +gating working as designed, not a failure. In-repo `Chart.yaml` carries the `CHART_VERSION` placeholder ([release](./release.md)); substitute any semver before templating a working copy.