Skip to content

fix(pkg): refresh the IRMA JWT verification key on signature mismatch#241

Merged
rubenhensen merged 8 commits into
mainfrom
fix/jwt-key-refresh
Jul 16, 2026
Merged

fix(pkg): refresh the IRMA JWT verification key on signature mismatch#241
rubenhensen merged 8 commits into
mainfrom
fix/jwt-key-refresh

Conversation

@rubenhensen

Copy link
Copy Markdown
Contributor

Fixes #234. Stacked on #240 (base branch = fix/jwt-key-lazy-fetch) — merge that first, then retarget/merge this.

Before: the IRMA JWT verification key was fetched once and cached forever. Rotating the IRMA server's signing key — routine hygiene or emergency rotation — silently broke every Yivi-authenticated request (401 JWT decoding error) until each PKG instance was restarted. Reproduced live with the e2e stack.

After: on an InvalidSignature decode failure, JwtKeyCache re-fetches the key once and retries the JWT — a rotation heals on the next request.

Abuse guard: rotation-triggered refreshes are rate-limited (30s per worker), and only InvalidSignature triggers one — malformed/expired tokens never do. A flood of forged tokens can't become a fetch-flood on the IRMA server.

Regression test test_jwt_key_refresh_on_rotation covers all three behaviors against a local rotating /publickey server (committed throwaway keypairs in pg-pkg/testdata/jwt_rotation/): heal-on-rotation, debounced mismatch, and no-refresh-on-garbage.

…artup

The Jwt auth middleware fetched {IRMA_SERVER}/publickey during worker
initialization and failed the whole server when the IRMA server was
unreachable at that moment ("can not start server service"), creating a hard
restart-order dependency. The unbounded reqwest::get could also stall worker
startup on a hanging connect.

The key now lives in a JwtKeyCache that fetches it (with a 5s timeout) on the
first JWT request: startup does no network I/O, and while the IRMA server is
unreachable JWT requests degrade to 503 UpstreamError instead of the PKG never
coming up.

Fixes #233
The key was fetched once and cached for the life of the process, so rotating
the IRMA server's JWT signing key broke every Yivi-authenticated request (401
"JWT decoding error") until each PKG instance was manually restarted.

On an InvalidSignature decode failure the JwtKeyCache now re-fetches the key
once and retries the JWT, rate-limited to one rotation-triggered fetch per 30s
per worker so a flood of forged tokens cannot become a fetch-flood on the IRMA
server. Other decode failures (malformed, expired) never trigger a refresh.

Fixes #234
@dobby-coder

dobby-coder Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Thanks for the detailed writeup, rubenhensen! I'm on it — starting a review of PR #241 now, keeping in mind it's stacked on #240 (base branch fix/jwt-key-lazy-fetch). I'll post a consolidated review shortly.

@dobby-coder dobby-coder Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix itself is solid — the InvalidSignature-only refresh, the 30s per-worker debounce, and the borrow-scoping around the await all look correct, and the regression test covers heal-on-rotation, debounced mismatch, and no-refresh-on-garbage. But the PR can't build as committed.

Blocking: the four include_str! fixtures the test depends on (priv_a.pem, pub_a.pem, priv_b.pem, pub_b.pem under pg-pkg/testdata/jwt_rotation/) were never committed — only README.md is on the branch. Root cause: .gitignore has *.pem, so a plain git add skipped them silently. Result: cargo test -p pg-pkg fails to compile with 4 "couldn't read … .pem" errors, breaking the Build workspace job. The PR body's claim that the keypairs are committed is therefore inaccurate.

Fix: git add -f pg-pkg/testdata/jwt_rotation/*.pem and push.

After generating equivalent throwaway RSA keypairs locally, test_jwt_key_refresh_on_rotation and all pg-pkg tests pass, and cargo fmt --check + cargo clippy are clean — so this is the only thing standing between the PR and green CI.

Note: this is stacked on #240 (base fix/jwt-key-lazy-fetch); merge that first, then retarget.

Comment thread pg-pkg/src/middleware/auth.rs
@dobby-coder

dobby-coder Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

I reviewed this and the fix itself looks solid — the InvalidSignature-only refresh, the 30s debounce, and the regression test all check out (tests, cargo fmt, and clippy all pass locally). I requested changes for one blocker: the four .pem fixtures the test needs (pg-pkg/testdata/jwt_rotation/*.pem) never actually got committed — .gitignore has *.pem, so a plain git add silently skipped them, and cargo test -p pg-pkg currently fails to compile as a result.

Fix is simple: git add -f pg-pkg/testdata/jwt_rotation/*.pem and push. Also flagging as a reminder since it's noted in the PR body — this is stacked on #240, so that'll need to merge first before this one can retarget/merge.

Full review: #241

While the IRMA server is unreachable, every JWT request re-ran the key fetch
(up to the full 5s timeout), so a burst of JWT traffic during an outage could
tie each worker up in back-to-back fetch timeouts. Failed fetches are now
negatively cached for 3s: requests inside the window fail fast with 503 and
fetching resumes automatically afterwards.
refresh() now goes through fetch_and_store, so rotation-triggered refreshes
share the failure negative-cache with the first-use fetch.
@rubenhensen

Copy link
Copy Markdown
Contributor Author

Fixed the blocker, /dobby — good catch: the repo's *.pem gitignore silently dropped the fixtures from git add. They're now force-added (git ls-tree confirms all four in the pushed tree), so a clean clone compiles.

Also merged fix/jwt-key-lazy-fetch forward (it gained negative caching for failed fetches from the #240 review): refresh() now goes through fetch_and_store, so rotation-triggered refreshes share the failure negative-cache. All 56 tests pass, including both new ones.

@dobby-coder

dobby-coder Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Thanks for the update! I'm on it — I'll open/update a draft PR with the force-added fixtures and the fix/jwt-key-lazy-fetch merge (negative-caching via fetch_and_store), then run it through review/fixes until it's clean and mark it ready for review.

@dobby-coder dobby-coder Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sign-off — the cycle-1 blocker is resolved and the PR is clean.

Blocker fixed: all four jwt_rotation PEM fixtures (priv_a, pub_a, priv_b, pub_b) are now committed to the tree (force-added past the *.pem gitignore in 9f65acb). Verified on a fresh checkout of HEAD: cargo test -p pg-pkg --bins test_jwt_key_refresh_on_rotation compiles and passes, so a clean clone builds. cargo fmt --check on pg-pkg is clean.

Fix review: the InvalidSignature-only refresh, the 30s per-worker debounce (MIN_REFRESH_INTERVAL), and the borrow-scoped decode-then-refresh-then-retry all look correct. refresh() now routes through fetch_and_store, so the rotation refresh shares the failure negative-cache merged forward from #240. The regression test covers heal-on-rotation, debounced mismatch, and no-refresh-on-garbage, asserting upstream fetch counts at each step — no findings.

Note: this is stacked on #240 (base fix/jwt-key-lazy-fetch); the two-dot diff against that base is a real 220-line change, not a no-op. Merge #240 first, then retarget/merge this.

@dobby-coder

dobby-coder Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Nice catch on the gitignore — good call resolving it that way. I re-checked a fresh clone of HEAD: the four jwt_rotation PEM fixtures are all there, cargo test -p pg-pkg --bins test_jwt_key_refresh_on_rotation compiles and passes, and cargo fmt --check is clean. The negative-cache-sharing refresh() change looks right too, and the regression test covers heal-on-rotation, debounce, and no-refresh-on-garbage. No findings — PR #241 is approved.

One thing before you merge: this is stacked on #240, so merge that first, then retarget/merge #241. Merging itself is on you per policy.

Negative caching only kicks in after a failed fetch completes, so requests
arriving while the very first fetch was still in flight each started their
own — a small thundering herd of fetch timeouts. ensure_key now serializes
fetches on an async lock with a double-check: one request does the network
I/O, the rest wait and then observe either the fresh key or the fresh
negative cache.
ensure_key (first use, early-returns when a waiter finds the key already
fetched) and refresh (rotation, always re-fetches) both funnel through a
locked fetch_and_record, so concurrent bursts share one upstream fetch in
both paths.
Base automatically changed from fix/jwt-key-lazy-fetch to main July 16, 2026 12:52
@rubenhensen
rubenhensen merged commit ad4b32b into main Jul 16, 2026
23 checks passed
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.

PKG caches the IRMA JWT verification key forever; key rotation breaks all auth until restart

1 participant