Skip to content

fix: verify refreshed id_token signature before storing claims in session - #1394

Merged
zandbelt merged 2 commits into
OpenIDC:masterfrom
Pyolar:master
Jul 31, 2026
Merged

fix: verify refreshed id_token signature before storing claims in session#1394
zandbelt merged 2 commits into
OpenIDC:masterfrom
Pyolar:master

Conversation

@Pyolar

@Pyolar Pyolar commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

oidc_refresh_token_grant_apply_id_token calls oidc_jwt_parse to parse the id_token from a refresh grant response, but oidc_jwt_parse only decrypts — it never validates the JWS signature. If the token endpoint returns an unsigned or improperly signed id_token, the unverified claims are stored directly in the session.

This adds a oidc_proto_jwt_verify call (matching the pattern already used in oidc_proto_idtoken_parse) between parsing and storing, so a token that fails signature validation is discarded.

…sion

oidc_refresh_token_grant_apply_id_token parsed the id_token from a refresh
grant but did not validate its JWS signature, so an unsigned token could
inject arbitrary claims into the session.
@zandbelt

Copy link
Copy Markdown
Member

Thanks for looking at this — the observation is correct and worth acting on. oidc_refresh_token_grant_apply_id_token really is the only place that takes an id_token apart without validating it: it calls oidc_jwt_parse with no keys and pushes the payload straight into oidc_session_set_idtoken_claims, which is what backs OIDCPassClaimsAs and require claim. Every other consumer (oidc_proto_idtoken_parse, oidc_proto_userinfo_request, the logout token path) verifies.

A note on the framing first: this isn't quite the vulnerability the description makes it out to be. The token arrives over the TLS-protected back-channel from the configured token endpoint under client authentication, and OpenID Connect Core 1.0 section 3.1.3.7 item 6 explicitly allows TLS server validation to stand in for signature validation in that case — which is why oidc_proto_idtoken_parse deliberately makes that exception for the code flow (see the comment above the is_code_flow test). To exploit this the OP itself has to be hostile, and a hostile OP can inject the same claims at login. So this is spec conformance and defence in depth rather than a fix for a privilege boundary that is currently crossable. Still worth doing.

On the patch itself, a few things:

  1. It doesn't prevent the store it says it prevents. oidc_session_set_idtoken() runs before the parse and isn't moved, so with OIDCStoreIDToken On the unverified serialized token is still persisted in the session and handed out through OIDCPassIDTokenAs and the session info hook. Only the claims are dropped.

  2. It regresses unsigned id_tokens. oidc_proto_jwt_verify refuses "alg":"none" outright when no algorithm is pinned, and cannot succeed when OIDCIDTokenSignedResponseAlg none is configured. oidc_proto_idtoken_parse skips verification entirely in exactly that case, for the back-channel reason above — and a refresh response is the same back-channel. As written, a deployment against an OP that legitimately issues unsigned id_tokens keeps working at login and silently loses its refreshed claims and its session-expiry update.

  3. Encrypted id_tokens stay broken. The NULL decryption-keys argument to oidc_jwt_parse is left in place, so a JWE id_token still fails to parse here, where the login path builds the key set from the client secret, oidc_cfg_private_keys_get and the configured client keys.

  4. Signature alone isn't what section 12.2 asks for. There's no iss, aud/azp, exp or iat check, and — the one I'd care about most — nothing checks that the sub is the same as the one in the id_token issued at login. Section 12.2 requires that, and a refreshed id_token silently moving an established session to a different subject is a more interesting failure than an unsigned one.

  5. (oidc_cfg_t *)c drops const. The caller oidc_refresh_token_grant already has a non-const oidc_cfg_t *, so the parameter can just lose its const rather than casting it away — we try to keep const-drop casts out of the tree.

Rather than a partial copy of the block from id_token.c, I think this collapses into the function that already gets all of it right:

if (oidc_proto_idtoken_parse(r, c, provider, s_id_token, NULL /* nonce */, &id_token_jwt,
                             TRUE /* back-channel */) == FALSE) {
        oidc_warn(r, "refreshed id_token could not be validated, retaining the claims obtained earlier");
        return;
}

nonce == NULL is handled (it belongs to the authentication request, not to a refresh), and that one call brings the decryption keys, the none exception, signature verification and the section 12.2 payload checks. On top of that a sub comparison against the claims already in the session, and moving the oidc_session_set_idtoken() call behind the validation, covers points 1 through 4.

oidc_refresh_token_grant_apply_id_token took the id_token returned by the
token endpoint apart with a bare oidc_jwt_parse - no decryption keys, no
signature verification and none of the payload checks - and stored its claims
in the session, where they go on to feed OIDCPassClaimsAs and "require claim".

Run the same oidc_proto_idtoken_parse as for the id_token obtained at
authentication time, which is what OpenID Connect Core 1.0 section 12.2
requires and which also brings the decryption keys and the section 3.1.3.7
item 6 exception for an unsigned id_token received over the TLS-protected
back-channel; a refresh response is such a response, so an OP that
legitimately issues unsigned id_tokens keeps working. On top of that, refuse
a refreshed id_token whose "sub" differs from the one the session was
established with, as section 12.2 also requires: a refresh must not be able
to move an established session to a different subject.

An id_token that does not validate no longer contributes its claims, is no
longer persisted in its serialized form - that happened before the parse, so
until now it was stored even when the parse failed - and is no longer handed
back to the caller. The refresh grant itself still succeeds, so a bad id_token
does not cost the caller its refreshed access token.

Extends the signature check proposed by Sun Liqiang in the previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zandbelt
zandbelt merged commit e4b22a9 into OpenIDC:master Jul 31, 2026
11 of 12 checks passed
zandbelt added a commit that referenced this pull request Jul 31, 2026
GitHub deliberately withholds repository secrets from fork-triggered
workflows, so SONAR_TOKEN is empty there and sonar-scanner exits 3 with "Not
authorized or project not found" - a red check no contributor can act on, and
one that would train us to ignore the very check that is supposed to report
real findings. Skip the job on a fork pull request so it reports neutral
instead of failed; the same code is still analyzed on the push to master.

Surfaced by #1394, the first fork pull request to reach this workflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Pyolar

Pyolar commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review and the follow-up. Reusing oidc_proto_idtoken_parse is clearly the right shape, and the section 12.2 sub check was the piece I missed. Good to see this land with tests.

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