Skip to content

Commit 6b3194b

Browse files
NilsBarlaugtilgovi
andcommitted
fix: use up-to-date kid in JWT header when refreshing
Co-authored-by: Randall Leeds <[email protected]>
1 parent 06e2af8 commit 6b3194b

File tree

9 files changed

+100
-67
lines changed

9 files changed

+100
-67
lines changed

cypress/helpers/index.js

+13
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,16 @@ const deleteGrant = (id) =>
9898
"DELETE",
9999
Cypress.env("admin_url") + "/trust/grants/jwt-bearer/issuers/" + id,
100100
)
101+
102+
export const validateJwt = (jwt) =>
103+
cy.request({
104+
method: "POST",
105+
url: `${Cypress.env("client_url")}/oauth2/validate-jwt`,
106+
form: true,
107+
body: { jwt },
108+
})
109+
110+
export const rotateJwks = (set) =>
111+
cy.request("POST", `${Cypress.env("admin_url")}/keys/${set}`, {
112+
alg: "RS256",
113+
})

cypress/integration/oauth2/jwt.js

+6-9
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright © 2022 Ory Corp
22
// SPDX-License-Identifier: Apache-2.0
33

4-
import { createClient, prng } from "../../helpers"
4+
import { createClient, prng, validateJwt } from "../../helpers"
55

66
const accessTokenStrategies = ["opaque", "jwt"]
77

@@ -44,15 +44,12 @@ describe("OAuth 2.0 JSON Web Token Access Tokens", () => {
4444
expect(token.refresh_token).to.not.be.empty
4545
expect(token.access_token.split(".").length).to.equal(3)
4646
expect(token.refresh_token.split(".").length).to.equal(2)
47-
})
4847

49-
cy.request(`${Cypress.env("client_url")}/oauth2/validate-jwt`)
50-
.its("body")
51-
.then((body) => {
52-
console.log(body)
53-
expect(body.sub).to.eq("[email protected]")
54-
expect(body.client_id).to.eq(client.client_id)
55-
expect(body.jti).to.not.be.empty
48+
validateJwt(token.access_token).then(({ payload }) => {
49+
expect(payload.sub).to.eq("[email protected]")
50+
expect(payload.client_id).to.eq(client.client_id)
51+
expect(payload.jti).to.not.be.empty
52+
})
5653
})
5754
})
5855
})

cypress/integration/oauth2/refresh_token.js

+74-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
// Copyright © 2022 Ory Corp
22
// SPDX-License-Identifier: Apache-2.0
33

4-
import { createClient, prng } from "../../helpers"
4+
import { validate as uuidValidate } from "uuid"
5+
6+
import { createClient, prng, rotateJwks, validateJwt } from "../../helpers"
57

68
const accessTokenStrategies = ["opaque", "jwt"]
79

@@ -100,6 +102,77 @@ describe("The OAuth 2.0 Refresh Token Grant", function () {
100102
})
101103
})
102104
})
105+
106+
it("should refresh the Access and ID Token with newly rotated keys", function () {
107+
if (
108+
accessTokenStrategy === "opaque" ||
109+
(Cypress.env("jwt_enabled") !== "true" &&
110+
!Boolean(Cypress.env("jwt_enabled")))
111+
) {
112+
this.skip()
113+
}
114+
115+
const referrer = `${Cypress.env("client_url")}/empty`
116+
cy.visit(referrer, {
117+
failOnStatusCode: false,
118+
})
119+
120+
createClient({
121+
scope: "offline_access openid",
122+
redirect_uris: [referrer],
123+
grant_types: ["authorization_code", "refresh_token"],
124+
response_types: ["code"],
125+
token_endpoint_auth_method: "none",
126+
}).then((client) => {
127+
cy.authCodeFlowBrowser(client, {
128+
consent: {
129+
scope: ["offline_access", "openid"],
130+
},
131+
createClient: false,
132+
}).then((originalResponse) => {
133+
expect(originalResponse.status).to.eq(200)
134+
expect(originalResponse.body.refresh_token).to.not.be.empty
135+
136+
const originalToken = originalResponse.body.refresh_token
137+
138+
rotateJwks("hydra.jwt.access-token")
139+
rotateJwks("hydra.openid.id-token")
140+
141+
cy.refreshTokenBrowser(client, originalToken).then(
142+
(refreshedResponse) => {
143+
expect(refreshedResponse.status).to.eq(200)
144+
expect(refreshedResponse.body.refresh_token).to.not.be.empty
145+
146+
validateJwt(originalResponse.body.access_token)
147+
.its("body.header.kid")
148+
.then((originalKid) => {
149+
expect(originalKid).to.satisfy(uuidValidate)
150+
151+
validateJwt(refreshedResponse.body.access_token)
152+
.its("body.header.kid")
153+
.then((refreshedKid) => {
154+
expect(refreshedKid).to.satisfy(uuidValidate)
155+
expect(refreshedKid).to.not.eq(originalKid)
156+
})
157+
})
158+
159+
validateJwt(originalResponse.body.id_token)
160+
.its("body.header.kid")
161+
.then((originalKid) => {
162+
expect(originalKid).to.satisfy(uuidValidate)
163+
164+
validateJwt(refreshedResponse.body.id_token)
165+
.its("body.header.kid")
166+
.then((refreshedKid) => {
167+
expect(refreshedKid).to.satisfy(uuidValidate)
168+
expect(refreshedKid).to.not.eq(originalKid)
169+
})
170+
})
171+
},
172+
)
173+
})
174+
})
175+
})
103176
})
104177
})
105178
})

oauth2/.snapshots/TestUnmarshalSession-v1.11.8.json

-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@
3535
"subject": "[email protected]"
3636
},
3737
"extra": {},
38-
"kid": "public:hydra.jwt.access-token",
3938
"client_id": "auth-code-client",
4039
"consent_challenge": "2261efbd447044a1b2f76b05c6aca164",
4140
"exclude_not_before_claim": false,

oauth2/.snapshots/TestUnmarshalSession-v1.11.9.json

-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@
3535
"subject": "[email protected]"
3636
},
3737
"extra": {},
38-
"kid": "public:hydra.jwt.access-token",
3938
"client_id": "auth-code-client",
4039
"consent_challenge": "2261efbd447044a1b2f76b05c6aca164",
4140
"exclude_not_before_claim": false,

oauth2/handler.go

+2-47
Original file line numberDiff line numberDiff line change
@@ -701,15 +701,7 @@ func (h *Handler) getOidcUserInfo(w http.ResponseWriter, r *http.Request) {
701701
interim["jti"] = uuid.New()
702702
interim["iat"] = time.Now().Unix()
703703

704-
keyID, err := h.r.OpenIDJWTStrategy().GetPublicKeyID(ctx)
705-
if err != nil {
706-
h.r.Writer().WriteError(w, r, err)
707-
return
708-
}
709-
710-
token, _, err := h.r.OpenIDJWTStrategy().Generate(ctx, interim, &jwt.Headers{
711-
Extra: map[string]interface{}{"kid": keyID},
712-
})
704+
token, _, err := h.r.OpenIDJWTStrategy().Generate(ctx, interim, &jwt.Headers{})
713705
if err != nil {
714706
h.r.Writer().WriteError(w, r, err)
715707
return
@@ -1185,17 +1177,6 @@ func (h *Handler) oauth2TokenExchange(w http.ResponseWriter, r *http.Request) {
11851177
if accessRequest.GetGrantTypes().ExactOne(string(fosite.GrantTypeClientCredentials)) ||
11861178
accessRequest.GetGrantTypes().ExactOne(string(fosite.GrantTypeJWTBearer)) ||
11871179
accessRequest.GetGrantTypes().ExactOne(string(fosite.GrantTypePassword)) {
1188-
var accessTokenKeyID string
1189-
if h.c.AccessTokenStrategy(ctx, client.AccessTokenStrategySource(accessRequest.GetClient())) == "jwt" {
1190-
accessTokenKeyID, err = h.r.AccessTokenJWTStrategy().GetPublicKeyID(ctx)
1191-
if err != nil {
1192-
h.logOrAudit(err, r)
1193-
h.r.OAuth2Provider().WriteAccessError(ctx, w, accessRequest, err)
1194-
events.Trace(ctx, events.TokenExchangeError, events.WithRequest(accessRequest), events.WithError(err))
1195-
return
1196-
}
1197-
}
1198-
11991180
// only for client_credentials, otherwise Authentication is included in session
12001181
if accessRequest.GetGrantTypes().ExactOne(string(fosite.GrantTypeClientCredentials)) {
12011182
session.Subject = accessRequest.GetClient().GetID()
@@ -1213,7 +1194,6 @@ func (h *Handler) oauth2TokenExchange(w http.ResponseWriter, r *http.Request) {
12131194
}
12141195
}
12151196
session.ClientID = accessRequest.GetClient().GetID()
1216-
session.KID = accessTokenKeyID
12171197
session.DefaultSession.Claims.Issuer = h.c.IssuerURL(ctx).String()
12181198
session.DefaultSession.Claims.IssuedAt = time.Now().UTC()
12191199

@@ -1404,21 +1384,6 @@ func (h *Handler) updateSessionWithRequest(
14041384
request.GrantAudience(audience)
14051385
}
14061386

1407-
openIDKeyID, err := h.r.OpenIDJWTStrategy().GetPublicKeyID(ctx)
1408-
if err != nil {
1409-
x.LogError(r, err, h.r.Logger())
1410-
return nil, err
1411-
}
1412-
1413-
var accessTokenKeyID string
1414-
if h.c.AccessTokenStrategy(ctx, client.AccessTokenStrategySource(request.GetClient())) == "jwt" {
1415-
accessTokenKeyID, err = h.r.AccessTokenJWTStrategy().GetPublicKeyID(ctx)
1416-
if err != nil {
1417-
x.LogError(r, err, h.r.Logger())
1418-
return nil, err
1419-
}
1420-
}
1421-
14221387
obfuscatedSubject, err := h.r.ConsentStrategy().ObfuscateSubjectIdentifier(ctx, request.GetClient(), consent.ConsentRequest.Subject, consent.ConsentRequest.ForceSubjectIdentifier)
14231388
if e := &(fosite.RFC6749Error{}); errors.As(err, &e) {
14241389
x.LogAudit(r, err, h.r.AuditLogger())
@@ -1456,13 +1421,9 @@ func (h *Handler) updateSessionWithRequest(
14561421
session.DefaultSession = &openid.DefaultSession{}
14571422
}
14581423
session.DefaultSession.Claims = claims
1459-
session.DefaultSession.Headers = &jwt.Headers{Extra: map[string]interface{}{
1460-
// required for lookup on jwk endpoint
1461-
"kid": openIDKeyID,
1462-
}}
1424+
session.DefaultSession.Headers = jwt.NewHeaders()
14631425
session.DefaultSession.Subject = consent.ConsentRequest.Subject
14641426
session.Extra = consent.Session.AccessToken
1465-
session.KID = accessTokenKeyID
14661427
session.ClientID = request.GetClient().GetID()
14671428
session.ConsentChallenge = consent.ConsentRequestID
14681429
session.ExcludeNotBeforeClaim = h.c.ExcludeNotBeforeClaim(ctx)
@@ -1623,13 +1584,7 @@ func (h *Handler) createVerifiableCredential(w http.ResponseWriter, r *http.Requ
16231584
}
16241585
}
16251586

1626-
signingKeyID, err := h.r.OpenIDJWTStrategy().GetPublicKeyID(ctx)
1627-
if err != nil {
1628-
h.r.Writer().WriteError(w, r, errorsx.WithStack(err))
1629-
return
1630-
}
16311587
headers := jwt.NewHeaders()
1632-
headers.Add("kid", signingKeyID)
16331588
mapClaims, err := vcClaims.ToMapClaims()
16341589
if err != nil {
16351590
h.r.Writer().WriteError(w, r, errorsx.WithStack(err))

oauth2/session.go

+1-4
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ import (
2727
type Session struct {
2828
*openid.DefaultSession `json:"id_token"`
2929
Extra map[string]interface{} `json:"extra"`
30-
KID string `json:"kid"`
3130
ClientID string `json:"client_id"`
3231
ConsentChallenge string `json:"consent_challenge"`
3332
ExcludeNotBeforeClaim bool `json:"exclude_not_before_claim"`
@@ -116,9 +115,7 @@ func (s *Session) GetJWTClaims() jwt.JWTClaimsContainer {
116115
}
117116

118117
func (s *Session) GetJWTHeader() *jwt.Headers {
119-
return &jwt.Headers{
120-
Extra: map[string]interface{}{"kid": s.KID},
121-
}
118+
return jwt.NewHeaders()
122119
}
123120

124121
func (s *Session) Clone() fosite.Session {

oauth2/session_test.go

-1
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ func TestUnmarshalSession(t *testing.T) {
6565
Subject: "[email protected]",
6666
},
6767
Extra: map[string]interface{}{},
68-
KID: "public:hydra.jwt.access-token",
6968
ClientID: "auth-code-client",
7069
ConsentChallenge: "2261efbd447044a1b2f76b05c6aca164",
7170
ExcludeNotBeforeClaim: false,

test/e2e/oauth2-client/src/index.js

+4-3
Original file line numberDiff line numberDiff line change
@@ -260,23 +260,24 @@ app.get("/oauth2/revoke", (req, res) => {
260260
})
261261
})
262262

263-
app.get("/oauth2/validate-jwt", (req, res) => {
263+
app.post("/oauth2/validate-jwt", (req, res) => {
264264
const client = jwksClient({
265265
jwksUri: new URL("/.well-known/jwks.json", config.public).toString(),
266266
})
267267

268268
jwt.verify(
269-
req.session.oauth2_flow.token.access_token,
269+
req.body.jwt,
270270
(header, callback) => {
271271
client.getSigningKey(header.kid, function (err, key) {
272272
const signingKey = key.publicKey || key.rsaPublicKey
273273
callback(null, signingKey)
274274
})
275275
},
276+
{ complete: true },
276277
(err, decoded) => {
277278
if (err) {
278279
console.error(err)
279-
res.send(400)
280+
res.status(400).send(JSON.stringify({ error: err.toString() }))
280281
return
281282
}
282283

0 commit comments

Comments
 (0)