Skip to content

Report what a JWKS refresh fetched - #540

Merged
maennchen merged 1 commit into
erlef:mainfrom
ericmj:token-refresh-jwks-info
Aug 5, 2026
Merged

Report what a JWKS refresh fetched#540
maennchen merged 1 commit into
erlef:mainfrom
ericmj:token-refresh-jwks-info

Conversation

@ericmj

@ericmj ericmj commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

refresh_jwks is called by oidcc and its result is consumed by oidcc, so a callback
that fetches a key set has no way to report anything to whoever started the token
exchange. It returns {ok, Jwks} and the function that supplied it never hears from it
again.

That matters to any RP that stores its own JWKS rather than holding it in an
oidcc_provider_configuration_worker. When a token arrives with an unknown kid and the
refresh succeeds, the RP wants the new document and its expiry so it can persist them,
and there is no return path. hex.pm keeps provider metadata per organization in Postgres
shared across web nodes, so this is its normal case rather than an edge.

Neither workaround holds up. Writing the document into the process dictionary inside the
callback and reading it back afterwards works today, but only because the callback happens
to run in the calling process, which nothing in the API promises. Dropping refresh_jwks
and retrying from outside on {error, {no_matching_key_with_kid, _}} doesn't work at all:
unknown_kid_retry/3 wraps validation only, so an outside retry re-sends the token
request, and RFC 6749 §4.1.2 makes the authorization code single use.

So this adds a return path rather than a side channel.
oidcc_jwt_util:refresh_jwks_for_unknown_kid_fun() gains {ok, Jwks, Info} alongside the
existing {ok, Jwks}, and oidcc_token:retrieve_with_refresh/3 hands Info back.
Info is term() | undefined and oidcc never inspects it, it only carries it from the
callback to the caller.

Everything existing keeps its shape. A callback returning {ok, Jwks} behaves as before
and yields undefined. retrieve/3 delegates to retrieve_with_refresh/3 and drops the
third element, so its return is unchanged. refresh/3, jwt_profile/4 and
client_credentials/2 drop it too, since none of them exchange an authorization code and
the value has nowhere useful to go.

Info is non-undefined only when the callback ran and validation then succeeded against
the refreshed key set. unknown_kid_retry/3 invokes the callback at most once and returns
{error, _} if the retry still fails, and on this path only the ID token goes through it,
so there is at most one refresh per exchange.

`refresh_jwks` is the only way to recover from a token naming an unknown `kid`,
because oidcc refreshes the keys and retries validation without re-sending the
authorization code, which is single use. But the function returns a key and
nothing else, and oidcc consumes that, so a caller that fetched the key set has
no way to hand the document or its expiry back to its own caller. Persisting the
refreshed key set therefore requires a side channel, in practice the process
dictionary.

The function may now return `{ok, Jwks, Info}`, and `retrieve_with_refresh/3`
hands `Info` back. oidcc does not interpret it. `retrieve/3` and the two element
return are unchanged, and the other flows drop it.
@ericmj
ericmj marked this pull request as ready for review August 4, 2026 21:35
@maennchen
maennchen merged commit 20b3485 into erlef:main Aug 5, 2026
28 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.

2 participants