Report what a JWKS refresh fetched - #540
Merged
Merged
Conversation
`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
marked this pull request as ready for review
August 4, 2026 21:35
maennchen
approved these changes
Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
refresh_jwksis called byoidccand its result is consumed byoidcc, so a callbackthat 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 itagain.
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 unknownkidand therefresh 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_jwksand retrying from outside on
{error, {no_matching_key_with_kid, _}}doesn't work at all:unknown_kid_retry/3wraps validation only, so an outside retry re-sends the tokenrequest, 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 theexisting
{ok, Jwks}, andoidcc_token:retrieve_with_refresh/3handsInfoback.Infoisterm() | undefinedandoidccnever inspects it, it only carries it from thecallback to the caller.
Everything existing keeps its shape. A callback returning
{ok, Jwks}behaves as beforeand yields
undefined.retrieve/3delegates toretrieve_with_refresh/3and drops thethird element, so its return is unchanged.
refresh/3,jwt_profile/4andclient_credentials/2drop it too, since none of them exchange an authorization code andthe value has nowhere useful to go.
Infois non-undefinedonly when the callback ran and validation then succeeded againstthe refreshed key set.
unknown_kid_retry/3invokes 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.