[
https://issues.apache.org/jira/browse/KNOX-3512?focusedWorklogId=1047239&page=com.atlassian.jira.plugin.system.issuetabpanels:worklog-tabpanel#worklog-1047239
]
ASF GitHub Bot logged work on KNOX-3512:
----------------------------------------
Author: ASF GitHub Bot
Created on: 13/Oct/26 12:36
Start Date: 13/Oct/26 12:36
Worklog Time Spent: 10m
Work Description: smolnar82 opened a new pull request, #1451:
URL: https://github.com/apache/knox/pull/1451
[KNOX-3512](https://issues.apache.org/jira/browse/KNOX-3512) - Per-issuer CA
bundle (jwksCa) for trusted OIDC issuers
## What changes were proposed in this pull request?
Today, Knox fetches a trusted OIDC issuer's discovery document and JWKS
using the JVM default truststore. To trust a cluster whose API server uses a
self-signed CA (e.g. a k8s/k3s issuer), you have to import that CA into the
global truststore. That widens trust for every other TLS client in the gateway,
and every CA rotation needs a restart.
This PR adds an optional, per-issuer **`jwksCa`** to the trusted OIDC issuer
registry:
- **Two forms**
- inline PEM bundle (≤ 65,000 chars)
- Kubernetes Secret reference `secret:[<namespace>/]<name>#<key>`. The
namespace defaults to `knox`; name and key are required.
- **Pinned trust:** when set, that issuer's discovery and JWKS connections
trust *only* that bundle. The global truststore is never consulted or modified.
Hostname verification stays on.
- **Fail-closed:** if the issuer has a `jwksCa` that cannot be loaded
(missing Secret or key, bad PEM), its tokens are rejected (401). Knox never
falls back to the default truststore.
- **Unchanged when unset:** issuers without a `jwksCa` behave exactly as
before.
- **No restart needed for CA changes**
- Inline bundle: re-registering (delete + create, as the operator does)
applies immediately.
- Secret reference: the Secret is read lazily on first use, with no
background refresh. It is re-read:
- after `gateway.trusted.oidc.issuer.ca.secret.max.age.secs` (default
`1800`)
- on `POST .../trusted-oidc-issuers/refresh-jwks`
- after a TLS trust failure on a discovery or JWKS fetch. Knox then
retries the fetch once. This is rate-limited to once per 30s per issuer and is
not triggered by timeouts, refused connections or non-200 responses.
A re-read with unchanged content keeps the existing SSL context. A
failed periodic re-read keeps the last good bundle.
### Changes by area
- **Model / persistence** (`gateway-spi`, `gateway-server`)
- `TrustedOidcIssuer` gains `jwksCa`.
- A new `jwks_ca` column: `TEXT`, and `CLOB` on Oracle. It is added in the
CREATE scripts only, because the table is unreleased.
- New `JwksCa` utility: secret-reference parsing and PEM bundle validation.
- **REST API** (`gateway-service-knoxidf`)
- `POST` accepts `jwksCa`; `GET` returns it. CA certificates are public,
and the operator needs the value to detect drift.
- Returns `400 invalid_request` when:
- `jwksCa` is set with `dynamicJwks=false`
- the value is oversized
- a secret reference has bad syntax
- an inline bundle has no parseable X.509 certificate
The syntax check alone is enough to register a Secret reference; the
Secret itself may be created later.
- **Trust material** (`IssuerTrustProvider`)
- Builds an `SSLContext` from an in-memory keystore holding only the
bundle's certificates.
- Reads Secrets through the fabric8 client (the same kubeconfig/in-cluster
config as the ServiceAccount validator).
- Logs SHA-256 fingerprints only, never PEM or Secret contents.
- **Discovery and JWKS fetch**
- `OIDCDiscoveryHelper` uses a pinned HTTP client for issuers that have a
`jwksCa`.
- `DefaultTokenAuthorityService` builds the Nimbus `JWKSource` with a new
`IssuerTrustResourceRetriever` that wraps a `DefaultResourceRetriever` with the
issuer's socket factory. On a TLS trust failure it performs the Secret re-read
and single retry. The re-read lives here because Nimbus's retrying and
outage-tolerant layers can swallow the TLS cause.
- **SPI changes**
- `TrustedOidcIssuerService#getJwksTrust(String)`: new method returning
`Optional<IssuerJwksTrust>`.
- `JWTokenAuthority#verifyToken(..., IssuerJwksTrust)`: new default
method, so existing implementations still compile.
- `AbstractJWTFilter#resolveRegisteredIssuerJwks` now returns a
`RegisteredIssuerJwks(uris, jwksTrust)` record instead of `Set<URI>`. The URIs
and trust come from a single lookup, so they always belong together.
- **Signing-key rotation:** works without any config change. A token whose
`kid` is unknown makes the JWK source re-fetch the JWKS.
- **Config:** `gateway.trusted.oidc.issuer.ca.secret.max.age.secs` (default
`1800`).
- **Docs** (`knox-site/docs/knoxidf/`)
- `endpoints.md`: the new "Per-issuer CA bundle (`jwksCa`)" section covers
both forms, the 400 rules and the reload behaviour.
- `configuration.md`: the new property.
- `security.md`: new "Trusting self-signed issuer CAs" section. It covers:
- pinning
- the RBAC Knox's ServiceAccount needs: `get` on `secrets` in each
referenced namespace
- the shared-root option
- the HA caveat
- `federation.md`: a pointer to the new section.
### Follow-ups (other repos)
- knox-operator: add `jwksCa` to the `TrustedOidcIssuer` CRD and include it
in the drift check, so a CA change triggers delete + create.
- knox-helm: add a Role/RoleBinding granting the Knox ServiceAccount `get`
on Secrets in the referenced namespaces. This replaces the cacerts import and
the restart-on-rotation approach.
## How was this patch tested?
Unit and integration tests, run with `mvn install` on `gateway-util-common`,
`gateway-spi`, `gateway-spi-common`, `gateway-provider-security-jwt`,
`gateway-server` and `gateway-service-knoxidf`. Checkstyle, PMD and SpotBugs
all pass. Totals: gateway-spi 364, gateway-provider-security-jwt 578,
gateway-server 889 (1 skipped), gateway-service-knoxidf 246, all green. The
downstream modules (`gateway-service-knoxsso`, `gateway-service-knoxtoken`)
also compile.
New and extended tests:
- `JwksCaTest`: secret-reference parsing (with and without a namespace;
malformed values) and PEM bundles with 1 certificate, 2 certificates, or
garbage.
- `IssuerTrustProviderTest`: uses a stubbed Secret reader and an injectable
clock. Covers:
- the Secret is read once on first use and not again before max-age
- re-read after max-age keeps the same SSL context when the content is
unchanged and swaps it when it changed
- re-reads after TLS failures are rate-limited to one per 30s
- a missing Secret or key fails closed
- a failed periodic re-read keeps the old bundle
- the fabric8 reader handles `data` and `stringData`
- `IssuerTrustResourceRetrieverTest`: a TLS trust failure leads to one
re-read and one retry; a timeout or 404 does not.
- `IssuerJwksCaAcceptanceTest`: real Jetty HTTPS JWKS servers with two
independent self-signed CAs. Covers:
- issuers A and B each verify with their own CA
- CA rotation works without a restart
- A's CA cannot reach B, and neither the default truststore nor a plain
`HttpsURLConnection` trusts them, so no trust leaks
- after signing-key rotation, a token with a new `kid` verifies with no
config change
- `OIDCDiscoveryHelperTest`, `JdbcTrustedOidcIssuerServiceTest` (H2
round-trip, `getJwksTrust`, invalidation on register/deregister/refresh),
`TrustedOidcIssuersSchemaTest`, `TrustedOidcIssuersResourceTest` (the 400 cases
and list output), `JWTFederationFilterTokenExchangeTest` (trust passed through,
and a load failure leads to 401).
## Integration Tests
**New suite: `.github/workflows/tests/test_k8s_jwks_ca.py`** (11 tests). It
runs against the throwaway k3s cluster in the compose stack, a real OIDC issuer
at `https://k3s:6443` with a self-signed CA, and covers:
- invalid `jwksCa` values are rejected at registration with 400 and nothing
is stored
- inline CA: the exchange is accepted; with an unrelated CA it is rejected
(pinned)
- the CA does not leak into the default truststore: once the issuer has been
trusted through its `jwksCa`, the same issuer registered without one is still
rejected
- re-registering an inline CA applies without a restart
- a Secret reference works in the default (`knox`) namespace and in an
explicitly named one
- a missing Secret fails closed and works once the Secret is created and
`refresh-jwks` is called
- a missing Secret key fails closed
- a rotated Secret applies on `refresh-jwks`
- a rotated Secret alone also applies, after a TLS failure triggers the
re-read
**CI change:** the k3s CA is **no longer imported into the JVM cacerts**.
The custom `gateway-k8s-delegation.sh` entrypoint did nothing else, so it is
removed and the knox service runs plain `/gateway.sh` again.
`test_k8s_delegation.py` now registers the k3s issuer with its CA as `jwksCa`.
As a result, every positive k8s exchange in CI goes through the pinned
per-issuer path, including the AC4/AC5 delegation tests that verify real
signatures. Supporting fixture: a `knox` namespace manifest
(`k8s/manifests/knox-namespace.yaml`).
Result of the full compose run: `pylint` 10.00/10, **136 passed**:
```
tests-1 | ------------------------------------
tests-1 | Your code has been rated at 10.00/10
tests-1 |
tests-1 | Waiting for knox...
tests-1 | ============================= test session starts
==============================
tests-1 | platform linux
Issue Time Tracking
-------------------
Worklog Id: (was: 1047239)
Remaining Estimate: 0h
Time Spent: 10m
> KnoxIDF: per-issuer CA bundle (jwksCa) for trusted OIDC issuer discovery and
> JWKS fetches
> -----------------------------------------------------------------------------------------
>
> Key: KNOX-3512
> URL: https://issues.apache.org/jira/browse/KNOX-3512
> Project: Apache Knox
> Issue Type: Task
> Components: JWT, Server
> Affects Versions: 3.1.0
> Reporter: Sandor Molnar
> Assignee: Sandor Molnar
> Priority: Major
> Fix For: 3.1.0
>
> Time Spent: 10m
> Remaining Estimate: 0h
>
> *Problem*
> Knox fetches each trusted OIDC issuer's discovery document and JWKS over
> HTTPS using the global JVM/Knox truststore. Kubernetes apiservers acting as
> issuers often use self-signed certificates, each with its own CA. Today the
> only way to trust them is to import every CA into the global truststore at
> startup. That has two costs:
> - Every CA rotation needs a restart.
> - Trust widens for all TLS clients in Knox (LDAP and the rest).
> TrustedOidcIssuer has no way to carry a CA.
> *Proposal*
> Add an optional {{jwksCa}} field to trusted OIDC issuer registrations. It
> takes one of two forms:
> - Inline PEM: one or more {{---{-}{{-}}BEGIN CERTIFICATE{{-}}{-}---}} blocks.
> - Kubernetes Secret reference: {{{}secret:[<namespace>/]<name>#<key>{}}}.
> Namespace defaults to "knox", the other 2 parts are required. Knox reads it
> through the in-cluster Kubernetes API.
> +Behaviour:+
> - When {{jwksCa}} is set, that issuer's discovery and JWKS TLS connections
> trust only the given bundle, and hostname verification stays on. The global
> truststore is never changed, so other TLS clients in Knox do not trust the
> issuer CA.
> - When {{jwksCa}} is not set, nothing changes.
> - {{jwksCa}} is only accepted together with {{{}dynamicJwks: true{}}}.
> - If {{jwksCa}} is configured but cannot be loaded (Secret or key missing,
> unparsable PEM), verification fails closed and does not fall back to the
> global truststore.
> - CA changes take effect without a restart:
> -- Inline: re-registering the issuer replaces its trust material straight
> away.
> -- Secret reference: Knox reads the Secret lazily, with no background
> refresh. It reads it:
> --- on the first discovery or JWKS fetch for the issuer.
> --- on access, when the cached CA is older than
> {{gateway.trusted.oidc.issuer.ca.secret.max.age.secs}} (default
> {{{}1800{}}}). This picks up CAs removed from the bundle.
> --- after a fetch fails with a TLS trust error (handshake or certificate
> validation). Knox re-reads the Secret and retries once, at most once every
> 30s per issuer. Timeouts and non-200 responses don't trigger a re-read.
> --- on {{{}POST /trusted-oidc-issuers/refresh-jwks{}}}, which drops the
> cached CA so the next fetch reads it again.
> -- If the Secret can't be read the first time, verification fails closed. If
> a max-age re-read fails, Knox keeps the last working CA and logs the failure.
> - Rotating the issuer's signing key still needs no config change. When a
> token's kid is not in the cached JWKS, the JWKS is fetched again.
> *Changes*
> - {{{}TrustedOidcIssuer{}}}, {{RegisterIssuerRequest}} and the admin REST
> resource get the new jwksCa field. Registration validates it and returns 400
> invalid_request when:
> -- it is set but dynamicJwks is false
> -- it is too long
> -- a secret: value has the wrong syntax
> -- inline PEM does not parse
> - {{TRUSTED_OIDC_ISSUERS}} table: new {{jwks_ca}} column ({{{}TEXT{}}}, or
> {{CLOB}} on Oracle).
> - {{TrustedOidcIssuerService}} exposes per-issuer TLS trust. It is used by
> OIDC discovery (Apache HttpClient) and by the JWKS fetch (Nimbus
> {{DefaultResourceRetriever}} with a per-issuer {{{}SSLSocketFactory{}}}).
> - JWTokenAuthority gets an overload of verifyToken that takes the JWKS
> SSLSocketFactory.
> - Docs: new field, config properties and RBAC requirements. Also a
> shared-root option: if every cluster chains to one root CA, use the same
> bundle or Secret for every issuer.
> Acceptance criteria
> - Issuers with different self-signed CAs all validate.
> - Replacing an issuer's CA bundle (inline or Secret) takes effect without a
> Knox restart.
> - An unrelated TLS client in Knox does not trust the issuer CA.
> - Rotating an apiserver signing key needs no Knox config change.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)