Skip to content

test(e2e): cover workload identity against a real JWKS fetch - #782

Merged
lakhansamani merged 1 commit into
mainfrom
test/e2e-workload-identity
Aug 17, 2026
Merged

test(e2e): cover workload identity against a real JWKS fetch#782
lakhansamani merged 1 commit into
mainfrom
test/e2e-workload-identity

Conversation

@lakhansamani

Copy link
Copy Markdown
Contributor

Requested alongside #781. Closes the coverage gap that PR's finding exposed.

The gap

The RFC 7523 client_assertion path had no test where Authorizer actually fetches the signing keys. Every in-package test substitutes the fetch seam — which removes the single thing that decides whether the feature works in production: the address.

mock-oauth stands in for the cluster. It already generates an RS256 keypair, serves /jwks and has discovery, so it only needed an endpoint minting a JWT shaped like a projected ServiceAccount token. Four cases:

  • a workload authenticates as its bound service account
  • an assertion is single-use (replay refused)
  • a subject outside allowed_subjects is refused
  • a wrong-aud assertion is refused

Becoming the third caller of SafeHTTPClientAllowPrivate

The mirror is a compose-network service on a private address, so this needs the escape hatch — gated on Config.Env == E2EEnv, exactly as the SSO broker and webhook delivery already are. That function's doc comment asks for careful review before a third caller, so:

Why one is needed at all. Workload identity is Authorizer fetching a key document the operator points it at. Without this, the only end-to-end coverage of that fetch is no coverage.

Why the alternatives are worse. Relaxing the guard's ranges weakens a production control for a test. Hosting the mirror on a range the guard happens to permit couples the suite to a gap in the block list — there were two, and #781 closes them — so that approach loses its coverage the moment the gap is fixed correctly.

What is not relaxed. Scheme allow-list, one-shot DNS resolution and dial pinning (rebinding defence), redirect refusal, size cap, TLS verification. This widens which addresses are reachable and nothing else — the same trade, under the same flag, as the two existing callers.

The gate is asserted by outcome, not by reading the flag. A private address must be refused under production, under an empty env, and with a nil Config; and must be genuinely reachable (a real request, not just client construction) under e2e. An inverted condition, a drifting default, or a nil Config arriving from a new call site all fail there.

A false claim found while building it

The replay comment said K8s ServiceAccount tokens carry no jti, and justified the (iss,sub,iat,exp) fallback with it. Verified against a live cluster:

claims : ['aud', 'exp', 'iat', 'iss', 'jti', 'kubernetes.io', 'nbf', 'sub']
has jti: True

They do carry one. The fallback is still correct to keep — RFC 7523 permits omitting jti, and without the fallback such an assertion would have no single-use key at all — but not for the stated reason. Corrected in the code comment and the test comment; the docs repo copy is corrected in authorizerdev/docs#92.

The mock mints a jti for the same reason: real tokens have one, and without it two mints in the same second collide on the fallback key — which is exactly how the single-use test failed before I added it.

Verification

make test                exit 0 — 43 packages, 0 FAIL
make lint                exit 0
go build / go vet        OK
workload-identity.spec   4 passed (full compose stack, real fetch)

The RFC 7523 client_assertion path had no test where Authorizer actually
FETCHES the signing keys. Every in-package test substitutes the fetch
seam, which removes the one thing that decides whether the feature works
in production: the address.

mock-oauth stands in for the cluster — it already generates an RS256
keypair, serves /jwks and has discovery, so it only needed an endpoint
minting a JWT shaped like a projected ServiceAccount token. Four cases:
a workload authenticates, an assertion is single-use, an unlisted subject
is refused, and a wrong-audience assertion is refused.

Reaching a compose-network mirror needs the private-address escape hatch,
so clientauth becomes the third caller of SafeHTTPClientAllowPrivate,
gated on Config.Env == E2EEnv exactly as the SSO broker and webhook
delivery are. That function's doc asks for careful review before a third
caller; newSafeClient's comment carries the reasoning, including why the
alternatives are worse — relaxing the guard's ranges weakens a production
control for a test, and hosting the mirror on a range the guard happens
to permit couples the suite to a block-list gap (there were two; #781
closes them) and loses the coverage silently once that gap is fixed.

The gate is asserted by OUTCOME rather than by reading the flag: a
private address must be refused under production, under an empty env, and
with a nil Config, and must be genuinely reachable under e2e. An inverted
condition or a drifting default fails there.

Also corrects a false claim found while building this. The replay comment
said K8s SA tokens carry no jti, and the (iss,sub,iat,exp) fallback was
justified by that. Verified against a live cluster: a projected token's
claims are aud, exp, iat, iss, jti, kubernetes.io, nbf, sub — it does
carry one. The fallback is still correct to keep, for issuers that omit
jti as RFC 7523 permits, but not for the stated reason. The mock mints a
jti for the same reason: real tokens have one, and without it two mints
in the same second collide on the fallback key.
@lakhansamani
lakhansamani force-pushed the test/e2e-workload-identity branch from 8e071ed to c54874a Compare August 17, 2026 06:20
@lakhansamani
lakhansamani merged commit 058c0ac into main Aug 17, 2026
@lakhansamani
lakhansamani deleted the test/e2e-workload-identity branch August 17, 2026 06:20
lakhansamani added a commit that referenced this pull request Aug 19, 2026
* docs(changelog): cover #773-#783

Unreleased linked 50 PRs and none of #773-#783, so every change made
after rc.22 - including four security fixes - was missing from the
CHANGELOG a user reads at 2.4.0.

Refs #773, #774, #775, #776, #777, #778, #779, #781, #782, #783

* chore: bump web/app to authorizer-react 2.2.0

authorizer-react 2.2.0 is published on authorizer-js 4.0.0; drop the
-rc.7 pin. Also stamps the CHANGELOG's Unreleased section as 2.4.0.

* test(e2e): make the authorizer host ports overridable

The seven authorizer services published fixed host ports, so the suite
could not run on a machine already using 8080-8086 - it failed at
"address already in use" before any test ran. The mock services already
take this shape. Playwright reaches every service by compose DNS, so the
host mapping is for humans only and the defaults are unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant