Skip to content

Expose response headers from JWKS fetches - #1241

Open
smoltafet wants to merge 3 commits into
jpadilla:masterfrom
smoltafet:expose-jwks-response-headers-1166
Open

smoltafet wants to merge 3 commits into
jpadilla:masterfrom
smoltafet:expose-jwks-response-headers-1166

Conversation

@smoltafet

@smoltafet smoltafet commented Oct 24, 2026 •

Copy link
Copy Markdown

Fixes #1166. (This is only the header-exposure half; it does not implement the max-age caching behaviour discussed in #1165.)

Problem

PyJWKClient made no response headers available after a JWKS fetch, so an application implementing CDN-bypass on kid-miss could not align its logic with the provider's stated intent: a parsed max-age alone loses directives such as stale-while-revalidate=86400, and the full Cache-Control header was simply unreachable.

Change

Least-invasive API shape: a read-only attribute on the client, no signature or return-type changes.

  • PyJWKClient.fetch_data() captures the headers of every successful response (best-effort extraction from the urllib response's HTTPMessage, degrading to an empty message for responses without a header mapping) and stores them on the client.
  • New public property PyJWKClient.last_response_headers -> http.client.HTTPMessage returns a copy of the full, unparsed header set from the most recent successful fetch — e.g. Cache-Control: public, max-age=600, stale-while-revalidate=86400 is available verbatim. It returns the message type rather than a plain dict because a field can appear on multiple lines and some must not be combined: RFC 9110 §5.3 singles out Set-Cookie, whose values contain commas (e.g. in Expires). Every field line is preserved verbatim, in order, and repeated fields are read with get_all(name). (Thanks to @Graph3x for catching that the earlier dict shape dropped/corrupted repeated lines.)
  • Cached path: calls served from the JWK Set cache perform no fetch, so the property keeps describing the last actual network response; a forced refresh replaces it, and a failed fetch does not clear the previously stored headers. It is empty before the first successful fetch.

Tests

9 new tests in tests/test_jwks_client.py with mocked urlopen responses carrying a real http.client.HTTPMessage: full Cache-Control exposure (incl. stale-while-revalidate), preservation of repeated field lines (repeated Cache-Control and Set-Cookie read back separately via get_all()), availability after get_signing_keys(), cache-hit preservation, refresh replacement, failed-fetch preservation, header-less responses, copy semantics, and the pre-fetch empty state.

pytest tests/test_jwks_client.py — 57 passed. Full suite — 481 passed, 4 skipped (pristine baseline: 472 passed, 4 skipped; zero new failures).

@smoltafet

Copy link
Copy Markdown
Author

Hi @jpadilla, could you take a look at this when you have a moment? Happy to fix up anything that needs changing. Thanks!

@auvipy
auvipy self-requested a review October 26, 2026 05:58
@Graph3x

Graph3x commented Oct 26, 2026

Copy link
Copy Markdown

Hi @smoltafet! This change doesn't seem to parse the headers correctly. For example, it doesn't parse repeated field lines right and drops all except the last. I recommend checking out the relevant RFC: https://www.rfc-editor.org/rfc/rfc9110.html#name-field-lines-and-combined-fi.
Example of a response that breaks:

import io
from http.client import parse_headers
from urllib.response import addinfourl

from jwt.jwks_client import PyJWKClient

raw_headers = (
    b"Content-Type: application/json\r\n"
    b"Cache-Control: public\r\n"
    b"Cache-Control: max-age=3600\r\n"
    b"\r\n"
)
headers = parse_headers(io.BytesIO(raw_headers))

response = addinfourl(
    io.BytesIO(b'{"keys": []}'),
    headers,
    "https://example.com/.well-known/jwks.json",
    code=200,
)

print(response.headers)
print(PyJWKClient._extract_response_headers(response))

The proposed _extract_response_headers drops the first Cache-Control line and returns only max-age=3600 without the public directive.

I hope the example is clear. I might have of course missed something, so reach out if anything isn't clear!

RFC 9110 section 5.2: a field name can appear on multiple field
lines, and the combined value joins them with ", ". The dict
comprehension in _extract_response_headers kept only the last
line, so e.g. Cache-Control: public followed by
Cache-Control: max-age=3600 lost the public directive.

Group duplicate names (case-insensitively, keeping the first
spelling) and join their values. Adds a regression test using
the reporter's parse_headers example.
@smoltafet

Copy link
Copy Markdown
Author

@Graph3x fixed, thanks for the clear example. Repeated header lines are now combined with ", " per RFC 9110 instead of keeping only the last one, so your example returns Cache-Control as "public, max-age=3600". Added a regression test with that exact case. Full suite passes locally (481 passed, 4 skipped).

@Graph3x

Graph3x commented Oct 27, 2026

Copy link
Copy Markdown

Hi!

The new commit handles the exact example I posted, but not the underlying issue. Flattening the headers into a dict still breaks Set-Cookie (RFC 9110 §5.3 says it shouldn't be combined).

I'm not a maintainer, but given you have opened 40+ PRs across many repositories in the last 3 days, I doubt there's time for the back and forth this would need and it would likely take longer than writing this myself. Happy to put together an alternative PR if the maintainers would like one, or help with the review if they decide they prefer this design.

Cheers!

Graph3x pointed out the dict shape is the underlying problem, not just the combining: RFC 9110 section 5.3 says Set-Cookie must not be combined, and its values contain commas (Expires), so any dict either drops repeated lines or corrupts them.

last_response_headers now returns an http.client.HTTPMessage copy holding every field line verbatim, in order. Repeated fields are read with get_all(), e.g. get_all("Set-Cookie"). _extract_response_headers no longer combines anything. Tests updated to the new shape, plus a regression test with repeated Cache-Control and Set-Cookie lines.
@smoltafet

smoltafet commented Oct 27, 2026 •

Copy link
Copy Markdown
Author

@Graph3x you're right, the dict itself was the problem. Reworked it so last_response_headers returns an HTTPMessage copy with every field line kept separate, repeated Set-Cookie included, readable with get_all. Also my girl left me so I got the extra time now to contribute to open-source (she cheated on me).

This branch has not been deployed

No deployments
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.

Expose response headers from JWKS fetches

2 participants