Skip to content

Commit 81c5802

Browse files
committed
fix: include JWT claims in token hook payload
1 parent 3f86782 commit 81c5802

File tree

4 files changed

+198
-3
lines changed

4 files changed

+198
-3
lines changed

go.mod

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ replace github.com/ory/hydra-client-go/v2 => ./internal/httpclient
88

99
replace github.com/gobuffalo/pop/v6 => github.com/ory/pop/v6 v6.2.1-0.20241121111754-e5dfc0f3344b
1010

11+
replace github.com/ory/fosite => github.com/phooijenga/fosite v0.0.0-20250225211800-ea87e12044d7
12+
1113
require (
1214
github.com/ThalesIgnite/crypto11 v1.2.5
1315
github.com/bradleyjkemp/cupaloy/v2 v2.8.0

go.sum

+2-2
Original file line numberDiff line numberDiff line change
@@ -378,8 +378,6 @@ github.com/ory/analytics-go/v5 v5.0.1 h1:LX8T5B9FN8KZXOtxgN+R3I4THRRVB6+28IKgKBp
378378
github.com/ory/analytics-go/v5 v5.0.1/go.mod h1:lWCiCjAaJkKfgR/BN5DCLMol8BjKS1x+4jxBxff/FF0=
379379
github.com/ory/dockertest/v3 v3.10.1-0.20240704115616-d229e74b748d h1:By96ZSVuH5LyjXLVVMfvJoLVGHaT96LdOnwgFSLVf0E=
380380
github.com/ory/dockertest/v3 v3.10.1-0.20240704115616-d229e74b748d/go.mod h1:F2FIjwwAk6CsNAs//B8+aPFQF0t84pbM8oliyNXwQrk=
381-
github.com/ory/fosite v0.49.0 h1:KNqO7RVt/1X8F08/UI0Y+GRvcpscCWgjqvpLBQPRovo=
382-
github.com/ory/fosite v0.49.0/go.mod h1:FAn7IY+I6DjT1r29wMouPeRYq63DWUuBj++96uOS4mE=
383381
github.com/ory/go-acc v0.2.9-0.20230103102148-6b1c9a70dbbe h1:rvu4obdvqR0fkSIJ8IfgzKOWwZ5kOT2UNfLq81Qk7rc=
384382
github.com/ory/go-acc v0.2.9-0.20230103102148-6b1c9a70dbbe/go.mod h1:z4n3u6as84LbV4YmgjHhnwtccQqzf4cZlSk9f1FhygI=
385383
github.com/ory/go-convenience v0.1.0 h1:zouLKfF2GoSGnJwGq+PE/nJAE6dj2Zj5QlTgmMTsTS8=
@@ -404,6 +402,8 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6
404402
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
405403
github.com/phayes/freeport v0.0.0-20180830031419-95f893ade6f2 h1:JhzVVoYvbOACxoUmOs6V/G4D5nPVUW73rKvXxP4XUJc=
406404
github.com/phayes/freeport v0.0.0-20180830031419-95f893ade6f2/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE=
405+
github.com/phooijenga/fosite v0.0.0-20250225211800-ea87e12044d7 h1:OowGroy4LX9hrZMRGncDq7g3e/rzezXZZlMkdkhOkaM=
406+
github.com/phooijenga/fosite v0.0.0-20250225211800-ea87e12044d7/go.mod h1:FAn7IY+I6DjT1r29wMouPeRYq63DWUuBj++96uOS4mE=
407407
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A=
408408
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
409409
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=

oauth2/oauth2_jwt_bearer_test.go

+191-1
Original file line numberDiff line numberDiff line change
@@ -310,8 +310,9 @@ func TestJWTBearer(t *testing.T) {
310310
audience := reg.Config().OAuth2TokenURL(ctx).String()
311311
grantType := "urn:ietf:params:oauth:grant-type:jwt-bearer"
312312

313+
jti := uuid.NewString()
313314
token, _, err := signer.Generate(ctx, jwt.MapClaims{
314-
"jti": uuid.NewString(),
315+
"jti": jti,
315316
"iss": trustGrant.Issuer,
316317
"sub": trustGrant.Subject,
317318
"aud": audience,
@@ -339,6 +340,7 @@ func TestJWTBearer(t *testing.T) {
339340
require.ElementsMatch(t, hookReq.Request.GrantedScopes, expectedGrantedScopes)
340341
require.ElementsMatch(t, hookReq.Request.GrantedAudience, expectedGrantedAudience)
341342
require.Equal(t, expectedPayload, hookReq.Request.Payload)
343+
require.Equal(t, jti, hookReq.Request.JWTClaims["jti"])
342344

343345
claims := map[string]interface{}{
344346
"hooked": true,
@@ -561,3 +563,191 @@ func TestJWTBearer(t *testing.T) {
561563
t.Run("strategy=jwt", run("jwt"))
562564
})
563565
}
566+
567+
func TestJWTClientAssertion(t *testing.T) {
568+
ctx := context.Background()
569+
570+
reg := testhelpers.NewMockedRegistry(t, &contextx.Default{})
571+
reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "opaque")
572+
_, admin := testhelpers.NewOAuth2Server(ctx, t, reg)
573+
574+
set, kid := uuid.NewString(), uuid.NewString()
575+
keys, err := jwk.GenerateJWK(ctx, jose.RS256, kid, "sig")
576+
require.NoError(t, err)
577+
signer := jwk.NewDefaultJWTSigner(reg.Config(), reg, set)
578+
signer.GetPrivateKey = func(ctx context.Context) (interface{}, error) {
579+
return keys.Keys[0], nil
580+
}
581+
582+
client := &hc.Client{
583+
GrantTypes: []string{"client_credentials"},
584+
Scope: "offline_access",
585+
TokenEndpointAuthMethod: "private_key_jwt",
586+
JSONWebKeys: &x.JoseJSONWebKeySet{
587+
JSONWebKeySet: &jose.JSONWebKeySet{
588+
Keys: []jose.JSONWebKey{keys.Keys[0].Public()},
589+
},
590+
},
591+
}
592+
require.NoError(t, reg.ClientManager().CreateClient(ctx, client))
593+
594+
var newConf = func(client *hc.Client) *clientcredentials.Config {
595+
return &clientcredentials.Config{
596+
AuthStyle: goauth2.AuthStyleInParams,
597+
TokenURL: reg.Config().OAuth2TokenURL(ctx).String(),
598+
Scopes: strings.Split(client.Scope, " "),
599+
EndpointParams: url.Values{
600+
"client_assertion_type": {"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"},
601+
},
602+
}
603+
}
604+
var getToken = func(t *testing.T, conf *clientcredentials.Config) (*goauth2.Token, error) {
605+
return conf.Token(context.Background())
606+
}
607+
608+
var inspectToken = func(t *testing.T, token *goauth2.Token, cl *hc.Client, strategy string, checkExtraClaims bool) {
609+
introspection := testhelpers.IntrospectToken(t, &goauth2.Config{ClientID: cl.GetID(), ClientSecret: cl.Secret}, token.AccessToken, admin)
610+
611+
check := func(res gjson.Result) {
612+
assert.EqualValues(t, cl.GetID(), res.Get("client_id").String(), "%s", res.Raw)
613+
assert.EqualValues(t, cl.GetID(), res.Get("sub").String(), "%s", res.Raw)
614+
assert.EqualValues(t, reg.Config().IssuerURL(ctx).String(), res.Get("iss").String(), "%s", res.Raw)
615+
616+
assert.EqualValues(t, res.Get("nbf").Int(), res.Get("iat").Int(), "%s", res.Raw)
617+
assert.True(t, res.Get("exp").Int() >= res.Get("iat").Int()+int64(reg.Config().GetAccessTokenLifespan(ctx).Seconds()), "%s", res.Raw)
618+
619+
if checkExtraClaims {
620+
require.True(t, res.Get("ext.hooked").Bool())
621+
}
622+
}
623+
624+
check(introspection)
625+
assert.True(t, introspection.Get("active").Bool())
626+
assert.EqualValues(t, "access_token", introspection.Get("token_use").String())
627+
assert.EqualValues(t, "Bearer", introspection.Get("token_type").String())
628+
assert.EqualValues(t, "offline_access", introspection.Get("scope").String(), "%s", introspection.Raw)
629+
630+
if strategy != "jwt" {
631+
return
632+
}
633+
634+
body, err := x.DecodeSegment(strings.Split(token.AccessToken, ".")[1])
635+
require.NoError(t, err)
636+
jwtClaims := gjson.ParseBytes(body)
637+
assert.NotEmpty(t, jwtClaims.Get("jti").String())
638+
assert.NotEmpty(t, jwtClaims.Get("iss").String())
639+
assert.NotEmpty(t, jwtClaims.Get("client_id").String())
640+
assert.EqualValues(t, "offline_access", introspection.Get("scope").String(), "%s", introspection.Raw)
641+
642+
header, err := x.DecodeSegment(strings.Split(token.AccessToken, ".")[0])
643+
require.NoError(t, err)
644+
jwtHeader := gjson.ParseBytes(header)
645+
assert.NotEmpty(t, jwtHeader.Get("kid").String())
646+
assert.EqualValues(t, "offline_access", introspection.Get("scope").String(), "%s", introspection.Raw)
647+
648+
check(jwtClaims)
649+
}
650+
651+
var generateAssertion = func() (string, jwt.MapClaims, error) {
652+
claims := jwt.MapClaims{
653+
"jti": uuid.NewString(),
654+
"iss": client.GetID(),
655+
"sub": client.GetID(),
656+
"aud": reg.Config().OAuth2TokenURL(ctx).String(),
657+
"exp": time.Now().Add(time.Hour).Unix(),
658+
"iat": time.Now().Add(-time.Minute).Unix(),
659+
}
660+
headers := &jwt.Headers{Extra: map[string]interface{}{"kid": kid}}
661+
token, _, err := signer.Generate(ctx, claims, headers)
662+
return token, claims, err
663+
}
664+
665+
t.Run("case=unable to exchange invalid jwt", func(t *testing.T) {
666+
conf := newConf(client)
667+
conf.EndpointParams.Set("client_assertion", "not-a-jwt")
668+
_, err := getToken(t, conf)
669+
require.Error(t, err)
670+
assert.Contains(t, err.Error(), "Unable to verify the integrity of the 'client_assertion' value.")
671+
})
672+
673+
t.Run("case=should exchange for an access token", func(t *testing.T) {
674+
run := func(strategy string) func(t *testing.T) {
675+
return func(t *testing.T) {
676+
reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, strategy)
677+
678+
token, _, err := generateAssertion()
679+
require.NoError(t, err)
680+
681+
conf := newConf(client)
682+
conf.EndpointParams.Set("client_assertion", token)
683+
684+
result, err := getToken(t, conf)
685+
require.NoError(t, err)
686+
687+
inspectToken(t, result, client, strategy, false)
688+
}
689+
}
690+
691+
t.Run("strategy=opaque", run("opaque"))
692+
t.Run("strategy=jwt", run("jwt"))
693+
})
694+
695+
t.Run("should call token hook if configured", func(t *testing.T) {
696+
run := func(strategy string) func(t *testing.T) {
697+
return func(t *testing.T) {
698+
token, assertionClaims, err := generateAssertion()
699+
require.NoError(t, err)
700+
701+
hs := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
702+
assert.Equal(t, r.Header.Get("Content-Type"), "application/json; charset=UTF-8")
703+
704+
expectedGrantedScopes := []string{client.Scope}
705+
expectedPayload := map[string][]string{
706+
"grant_type": {"client_credentials"},
707+
"scope": {"offline_access"},
708+
}
709+
710+
var hookReq hydraoauth2.TokenHookRequest
711+
require.NoError(t, json.NewDecoder(r.Body).Decode(&hookReq))
712+
require.NotEmpty(t, hookReq.Session)
713+
require.Equal(t, hookReq.Session.Extra, map[string]interface{}{})
714+
require.NotEmpty(t, hookReq.Request)
715+
require.ElementsMatch(t, hookReq.Request.GrantedScopes, expectedGrantedScopes)
716+
require.Equal(t, expectedPayload, hookReq.Request.Payload)
717+
require.Equal(t, assertionClaims["jti"], hookReq.Request.JWTClaims["jti"])
718+
719+
claims := map[string]interface{}{
720+
"hooked": true,
721+
}
722+
723+
hookResp := hydraoauth2.TokenHookResponse{
724+
Session: flow.AcceptOAuth2ConsentRequestSession{
725+
AccessToken: claims,
726+
IDToken: claims,
727+
},
728+
}
729+
730+
w.WriteHeader(http.StatusOK)
731+
require.NoError(t, json.NewEncoder(w).Encode(&hookResp))
732+
}))
733+
defer hs.Close()
734+
735+
reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, strategy)
736+
reg.Config().MustSet(ctx, config.KeyTokenHook, hs.URL)
737+
738+
defer reg.Config().MustSet(ctx, config.KeyTokenHook, nil)
739+
740+
conf := newConf(client)
741+
conf.EndpointParams.Set("client_assertion", token)
742+
743+
result, err := getToken(t, conf)
744+
require.NoError(t, err)
745+
746+
inspectToken(t, result, client, strategy, true)
747+
}
748+
}
749+
750+
t.Run("strategy=opaque", run("opaque"))
751+
t.Run("strategy=jwt", run("jwt"))
752+
})
753+
}

oauth2/token_hook.go

+3
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ type Request struct {
4141
GrantTypes []string `json:"grant_types"`
4242
// Payload is the requests payload.
4343
Payload map[string][]string `json:"payload"`
44+
// JWTClaims contains the decoded JWT claims (RFC 7523).
45+
JWTClaims map[string]interface{} `json:"jwt_claims"`
4446
}
4547

4648
// TokenHookRequest is the request body sent to the token hook.
@@ -177,6 +179,7 @@ func TokenHook(reg interface {
177179
GrantedAudience: requester.GetGrantedAudience(),
178180
GrantTypes: requester.GetGrantTypes(),
179181
Payload: requester.Sanitize([]string{"assertion"}).GetRequestForm(),
182+
JWTClaims: requester.GetJWTClaims(),
180183
}
181184

182185
reqBody := TokenHookRequest{

0 commit comments

Comments
 (0)