Skip to content

Return the provider document from the configuration loaders - #537

Merged
maennchen merged 1 commit into
erlef:mainfrom
ericmj:provider-configuration-raw-document
Aug 4, 2026
Merged

Return the provider document from the configuration loaders#537
maennchen merged 1 commit into
erlef:mainfrom
ericmj:provider-configuration-raw-document

Conversation

@ericmj

@ericmj ericmj commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

#oidcc_provider_configuration{} can't be re-encoded back into the document it came
from. extra_fields holds only the keys the decoder didn't recognize, every other key is
coerced into a typed record field, and issuer_regex isn't an OpenID Discovery field at
all. So an RP that wants to persist what a provider served, rather than re-fetch it, has
no way to get it.

That's the normal shape for anything not using oidcc_provider_configuration_worker.
hex.pm runs OIDC SSO per organization: the provider config lives in Postgres, is shared
across web nodes, and every request rebuilds the client context from the stored document
with oidcc_client_context:from_manual/4. Storing the record isn't an option, since it's
an Erlang record whose shape changes between oidcc versions and a serialized copy breaks
on upgrade. The JSON is the stable thing to store.

With no way to ask oidcc for it, the workaround is to capture it out of band. hex.pm's
oidcc_http_adapter stashes every response in the process dictionary and the caller
reads it back and decodes the body a second time. That works, but it's correct only by
accident: the stash is last-write-wins and unkeyed, so it depends on load_configuration/2
and load_jwks/2 each issuing exactly one HTTP request. Any change inside oidcc that adds
a second request to either one, a redirect follow or a retry, silently hands back the
wrong document with no error. That's a hard dependency on an implementation detail oidcc
never promised.

This adds load_configuration_raw/2 and load_jwks_raw/2, returning
{ok, {Configuration, Expiry, Document}}. The existing 2-arity functions become one-line
delegations that drop the third element, so nothing existing changes shape. Document is
the decoded map as it came off the wire, taken before decode_configuration/2 merges
document_overrides into it, so what comes back is what the provider sent and not what
oidcc made of it.

Two alternatives I discarded. A new field on #oidcc_provider_configuration{} would reach
the Elixir struct for free, but the hrl is public API shipped in the Hex package, so it
forces downstream recompilation, and #oidcc_client_context{} embeds the configuration
record and gets passed to every operation, so every context would carry a second copy of
the document. An opts flag that changes the return shape has no precedent anywhere in
src/ or lib/, and dialyzer can't express it. The closest existing precedent for a wider
success tuple is oidcc_profile:apply_profiles/2 returning {ok, ClientContext, Opts}.

Splitting the loaders surfaced a separate crash, fixed here because the fix is two lines
in the code this PR already moves. Both loaders assumed the decoded JSON was an object. A
provider answering null under a JSON content type reached maps:merge/2 inside
decode_configuration/2, and for JWKS a scalar reached jose_jwk:from/1, either of which
takes the calling process down. Both now return {error, {invalid_document, Document}}, a
new member of oidcc_provider_configuration:error(). JWKS accepts an object or a bare
array, since jose_jwk:from/1 takes both and some providers serve the array form.

The Elixir wrappers mirror both functions and @type entries are updated by hand as usual.

maennchen
maennchen previously approved these changes Aug 4, 2026
@ericmj
ericmj marked this pull request as ready for review August 4, 2026 13:56
@maennchen

Copy link
Copy Markdown
Member

@ericmj Hm, you have a conflict.

`load_configuration/2` and `load_jwks/2` hand back only the decoded record and
an expiry, and the record cannot be re-encoded losslessly: `extra_fields` holds
only the keys the decoder did not recognize, every other key is coerced into a
typed record field, and `issuer_regex` is not an OpenID Discovery field at all.
Callers that persist provider metadata therefore have no way to get at what the
provider actually served.

`load_configuration_raw/2` and `load_jwks_raw/2` return the decoded JSON
document alongside the record. The existing functions delegate to them and drop
it, so there is one loading path. The document is what came off the wire, before
the `document_overrides` quirk is merged in while decoding.

Both loaders now also reject a body that parses but is not a document. A
discovery response of `null` used to reach `maps:merge/2` in
`decode_configuration/2` and take the caller down; a JWKS may legitimately be a
bare array of keys, so only scalars are rejected there and the type says so.
@ericmj
ericmj force-pushed the provider-configuration-raw-document branch from 9f79cc1 to 3d2cfb3 Compare August 4, 2026 14:06
@maennchen
maennchen merged commit ad41082 into erlef:main Aug 4, 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