Skip to content

fix: use up-to-date kid in JWT header when refreshing #3942

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from

Conversation

NilsBarlaug
Copy link

@NilsBarlaug NilsBarlaug commented Feb 12, 2025

When you rotate any of the JWT signing keys (hydra.jwt.access-token/hydra.openid.id-token, Hydra will (as expected) sign all new JWTs with the new key - including any JWTs from a refresh token grant flow. However, for any refresh token chain that existed before the key rotation Hydra will incorrectly use the kid of the old key in the JWT header. This effectively breaks/poisons all issued refresh tokens on key rotation since all future access/id tokens they provide will be invalid. See the new test for a reproducible example.

I consider this to be a critical bug.

Related issue(s)

#3573, #3719

Checklist

  • I have read the contributing guidelines.
  • I have referenced an issue containing the design document if my change
    introduces a new feature.
  • I am following the
    contributing code guidelines.
  • I have read the security policy.
  • I confirm that this pull request does not address a security
    vulnerability. If this pull request addresses a security vulnerability, I
    confirm that I got the approval (please contact
    [email protected]) from the maintainers to push
    the changes.
  • I have added tests that prove my fix is effective or that my feature
    works.
  • (Not relevant) I have added or changed the documentation.

Further Comments

The underlying problem is that the JWT signing mechanism doesn't control the kid in the JWT header - it's picked separately and stored in Session. So ideally, we would either instruct Fosite which key to use or respect the key that Fosite chooses. As pointed out in #3573, if we do not set the KIDs Fosite will use the correct kid. However, because we include the KIDs in the token hook upfront before we sign the JWTs it would be a breaking change to omit it. It seems non-trivial (for me at least) to refactor the codebase so the KIDs used in the actual JWT signing code is the source of truth for the KIDs provided in the token hook - mostly because the hook must run before the signing.

Therefore, I've opted for being consistent with how it's done for the initial authorization/token exchange. On the start of every refresh token grant we set the KIDs in the Session to correspond to the newest keys. This is not a perfect solution, but is a huge improvement over the current situation without making big changes. It goes from "guaranteed" breaking of all JWTs from existing refresh tokens to "very unlikely". Given the severity of this bug, I think it makes sense to first make a smaller fix and then follow up later with larger structural improvements.

What do you think?

@NilsBarlaug NilsBarlaug requested review from aeneasr and a team as code owners February 12, 2025 08:38
@NilsBarlaug NilsBarlaug marked this pull request as draft February 12, 2025 08:39
@NilsBarlaug NilsBarlaug marked this pull request as ready for review February 12, 2025 09:05
Copy link
Member

@aeneasr aeneasr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great fix! I think this is the best workaround-y solution to address this :)

Since I‘m reviewing this on my phone it would be good for someone else to also review this. The tests give me decent confidence that this is working as intended.

Copy link
Collaborator

@alnr alnr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like it. Optionally add a comment to explain the workaround.

@@ -1021,6 +1021,32 @@ func (h *Handler) oauth2TokenExchange(w http.ResponseWriter, r *http.Request) {
}
}

if accessRequest.GetGrantTypes().ExactOne(string(fosite.GrantTypeRefreshToken)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe a short comment explaining why we do this here?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if accessRequest.GetGrantTypes().ExactOne(string(fosite.GrantTypeRefreshToken)) {
// When refreshing tokens, we want to ensure to use the latest key-id available for signing the
// potentially JWT-formatted tokens.
if accessRequest.GetGrantTypes().ExactOne(string(fosite.GrantTypeRefreshToken)) {

Copy link
Member

@aeneasr aeneasr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! To remarks, otherwise LGTM

@@ -1021,6 +1021,32 @@ func (h *Handler) oauth2TokenExchange(w http.ResponseWriter, r *http.Request) {
}
}

if accessRequest.GetGrantTypes().ExactOne(string(fosite.GrantTypeRefreshToken)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if accessRequest.GetGrantTypes().ExactOne(string(fosite.GrantTypeRefreshToken)) {
// When refreshing tokens, we want to ensure to use the latest key-id available for signing the
// potentially JWT-formatted tokens.
if accessRequest.GetGrantTypes().ExactOne(string(fosite.GrantTypeRefreshToken)) {

}
}

openIDKeyID, err := h.r.OpenIDJWTStrategy().GetPublicKeyID(ctx)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please add an if condition to only fetch this key if the flow is an openid flow. I think it should be enough to check if GrantedScope contains openid. This will reduce DB load in cases where we don't have an OpenID flow.

@tilgovi
Copy link

tilgovi commented Apr 5, 2025

It may be that this can be solved more simply by letting fosite handle this, now that ory/fosite#799 is released. I put up a PR to see about that. I kept the E2E tests and the commit authorship, but added myself as a co-author. Instead of adding go code to set the right kid, I just removed all of the code that sets a kid at all.

@aeneasr
Copy link
Member

aeneasr commented Apr 14, 2025

I agree that #3973 is the better approach now that this is solved in fosite!

@aeneasr aeneasr closed this Apr 14, 2025
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.

4 participants