Skip to content

Commit adca18b

Browse files
committed
feat: [ory#631] Support for client_assertion JWE
1 parent 73ba37d commit adca18b

File tree

5 files changed

+457
-72
lines changed

5 files changed

+457
-72
lines changed

client.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,17 @@ type OpenIDConnectClient interface {
7171
GetTokenEndpointAuthSigningAlgorithm() string
7272
}
7373

74+
// ClientWithAllowedVerificationKeys adds a security control to the client configuration to only allow
75+
// specific verification keys. This ensures that a key that is valid for client X can't be used for client Y
76+
// unless allowed. This becomes especially important for cases where the clients are controlled by third-parties
77+
// and are issued specific keys from a central organization, which may be the OP's org or a central regulatory authority,
78+
// and the security controls of the clients cannot be guaranteed.
79+
type ClientWithAllowedVerificationKeys interface {
80+
// AllowedVerificationKeys provides a list of key IDs that can be used in the JWT
81+
// header for private_key_jwt authentication and for JWT bearer grant flow
82+
AllowedVerificationKeys() []string
83+
}
84+
7485
// ResponseModeClient represents a client capable of handling response_mode
7586
type ResponseModeClient interface {
7687
// GetResponseMode returns the response modes that client is allowed to send

client_authentication.go

Lines changed: 63 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,13 @@ import (
88
"crypto/ecdsa"
99
"crypto/rsa"
1010
"encoding/json"
11-
"fmt"
1211
"net/http"
1312
"net/url"
1413
"time"
1514

1615
"github.com/ory/x/errorsx"
1716

1817
"github.com/go-jose/go-jose/v3"
19-
"github.com/pkg/errors"
2018

2119
"github.com/ory/fosite/token/jwt"
2220
)
@@ -54,7 +52,7 @@ func (f *Fosite) findClientPublicJWK(ctx context.Context, oidcClient OpenIDConne
5452
}
5553

5654
// AuthenticateClient authenticates client requests using the configured strategy
57-
// `Fosite.ClientAuthenticationStrategy`, if nil it uses `Fosite.DefaultClientAuthenticationStrategy`
55+
// `ClientAuthenticationStrategy`, if nil it uses `DefaultClientAuthenticationStrategy`
5856
func (f *Fosite) AuthenticateClient(ctx context.Context, r *http.Request, form url.Values) (Client, error) {
5957
if s := f.Config.GetClientAuthenticationStrategy(ctx); s != nil {
6058
return s(ctx, r, form)
@@ -71,81 +69,80 @@ func (f *Fosite) DefaultClientAuthenticationStrategy(ctx context.Context, r *htt
7169
return nil, errorsx.WithStack(ErrInvalidRequest.WithHintf("The client_assertion request parameter must be set when using client_assertion_type of '%s'.", clientAssertionJWTBearerType))
7270
}
7371

74-
var clientID string
75-
var client Client
76-
77-
token, err := jwt.ParseWithClaims(assertion, jwt.MapClaims{}, func(t *jwt.Token) (interface{}, error) {
78-
var err error
79-
clientID, _, err = clientCredentialsFromRequestBody(form, false)
80-
if err != nil {
81-
return nil, err
72+
// for backward compatibility
73+
if f.JWTHelper == nil {
74+
f.JWTHelper = &JWTHelper{
75+
JWTStrategy: nil,
76+
Config: f.Config,
8277
}
78+
}
8379

84-
if clientID == "" {
85-
claims := t.Claims
86-
if sub, ok := claims["sub"].(string); !ok {
87-
return nil, errorsx.WithStack(ErrInvalidClient.WithHint("The claim 'sub' from the client_assertion JSON Web Token is undefined."))
88-
} else {
89-
clientID = sub
90-
}
91-
}
80+
// Parse the assertion
81+
token, parsedToken, isJWE, err := f.newToken(assertion, "client_assertion", ErrInvalidClient)
82+
if err != nil {
83+
return nil, errorsx.WithStack(ErrInvalidClient.WithHint("Unable to parse the client_assertion").WithWrap(err).WithDebug(err.Error()))
84+
}
9285

93-
client, err = f.Store.GetClient(ctx, clientID)
94-
if err != nil {
95-
return nil, errorsx.WithStack(ErrInvalidClient.WithWrap(err).WithDebug(err.Error()))
96-
}
86+
claims := token.Claims
9787

98-
oidcClient, ok := client.(OpenIDConnectClient)
99-
if !ok {
100-
return nil, errorsx.WithStack(ErrInvalidRequest.WithHint("The server configuration does not support OpenID Connect specific authentication methods."))
101-
}
88+
// Validate client
89+
clientID, _, err := clientCredentialsFromRequestBody(form, false)
90+
if err != nil {
91+
return nil, err
92+
}
10293

103-
switch oidcClient.GetTokenEndpointAuthMethod() {
104-
case "private_key_jwt":
105-
break
106-
case "none":
107-
return nil, errorsx.WithStack(ErrInvalidClient.WithHint("This requested OAuth 2.0 client does not support client authentication, however 'client_assertion' was provided in the request."))
108-
case "client_secret_post":
109-
fallthrough
110-
case "client_secret_basic":
111-
return nil, errorsx.WithStack(ErrInvalidClient.WithHintf("This requested OAuth 2.0 client only supports client authentication method '%s', however 'client_assertion' was provided in the request.", oidcClient.GetTokenEndpointAuthMethod()))
112-
case "client_secret_jwt":
113-
fallthrough
114-
default:
115-
return nil, errorsx.WithStack(ErrInvalidClient.WithHintf("This requested OAuth 2.0 client only supports client authentication method '%s', however that method is not supported by this server.", oidcClient.GetTokenEndpointAuthMethod()))
94+
if clientID == "" {
95+
if isJWE {
96+
return nil, errorsx.WithStack(ErrInvalidClient.WithHint("The 'client_id' must be part of the request when encrypted client_assertion is used."))
11697
}
11798

118-
if oidcClient.GetTokenEndpointAuthSigningAlgorithm() != fmt.Sprintf("%s", t.Header["alg"]) {
119-
return nil, errorsx.WithStack(ErrInvalidClient.WithHintf("The 'client_assertion' uses signing algorithm '%s' but the requested OAuth 2.0 Client enforces signing algorithm '%s'.", t.Header["alg"], oidcClient.GetTokenEndpointAuthSigningAlgorithm()))
99+
if sub, ok := claims["sub"].(string); !ok {
100+
return nil, errorsx.WithStack(ErrInvalidClient.WithHint("The claim 'sub' from the client_assertion JSON Web Token is undefined."))
101+
} else {
102+
clientID = sub
120103
}
121-
switch t.Method {
122-
case jose.RS256, jose.RS384, jose.RS512:
123-
return f.findClientPublicJWK(ctx, oidcClient, t, true)
124-
case jose.ES256, jose.ES384, jose.ES512:
125-
return f.findClientPublicJWK(ctx, oidcClient, t, false)
126-
case jose.PS256, jose.PS384, jose.PS512:
127-
return f.findClientPublicJWK(ctx, oidcClient, t, true)
128-
case jose.HS256, jose.HS384, jose.HS512:
129-
return nil, errorsx.WithStack(ErrInvalidClient.WithHint("This authorization server does not support client authentication method 'client_secret_jwt'."))
130-
default:
131-
return nil, errorsx.WithStack(ErrInvalidClient.WithHintf("The 'client_assertion' request parameter uses unsupported signing algorithm '%s'.", t.Header["alg"]))
132-
}
133-
})
104+
}
105+
106+
client, err := f.Store.GetClient(ctx, clientID)
134107
if err != nil {
135-
// Do not re-process already enhanced errors
136-
var e *jwt.ValidationError
137-
if errors.As(err, &e) {
138-
if e.Inner != nil {
139-
return nil, e.Inner
140-
}
141-
return nil, errorsx.WithStack(ErrInvalidClient.WithHint("Unable to verify the integrity of the 'client_assertion' value.").WithWrap(err).WithDebug(err.Error()))
142-
}
108+
return nil, errorsx.WithStack(ErrInvalidClient.WithHint("The requested OAuth 2.0 Client could not be authenticated.").WithWrap(err).WithDebug(err.Error()))
109+
}
110+
111+
oidcClient, ok := client.(OpenIDConnectClient)
112+
if !ok {
113+
return nil, errorsx.WithStack(ErrInvalidRequest.WithHint("The server configuration does not support OpenID Connect specific authentication methods."))
114+
}
115+
116+
switch oidcClient.GetTokenEndpointAuthMethod() {
117+
case "private_key_jwt":
118+
break
119+
case "none":
120+
return nil, errorsx.WithStack(ErrInvalidClient.WithHint("This requested OAuth 2.0 client does not support client authentication, however 'client_assertion' was provided in the request."))
121+
case "client_secret_post":
122+
fallthrough
123+
case "client_secret_basic":
124+
return nil, errorsx.WithStack(ErrInvalidClient.WithHintf("This requested OAuth 2.0 client only supports client authentication method '%s', however 'client_assertion' was provided in the request.", oidcClient.GetTokenEndpointAuthMethod()))
125+
case "client_secret_jwt":
126+
fallthrough
127+
default:
128+
return nil, errorsx.WithStack(ErrInvalidClient.WithHintf("This requested OAuth 2.0 client only supports client authentication method '%s', however that method is not supported by this server.", oidcClient.GetTokenEndpointAuthMethod()))
129+
}
130+
131+
// Validate signature
132+
if !isJWE && oidcClient.GetTokenEndpointAuthSigningAlgorithm() != "" && oidcClient.GetTokenEndpointAuthSigningAlgorithm() != parsedToken.Headers[0].Algorithm {
133+
return nil, errorsx.WithStack(ErrInvalidClient.WithHintf("The client_assertion uses signing algorithm '%s', but the requested OAuth 2.0 Client enforces signing algorithm '%s'.", parsedToken.Headers[0].Algorithm, oidcClient.GetTokenEndpointAuthSigningAlgorithm()))
134+
}
135+
136+
if token, parsedToken, err = f.ValidateParsedAssertionWithClient(ctx, "client_assertion", assertion, token, parsedToken, oidcClient, false, ErrInvalidClient); err != nil {
143137
return nil, err
144-
} else if err := token.Claims.Valid(); err != nil {
145-
return nil, errorsx.WithStack(ErrInvalidClient.WithHint("Unable to verify the request object because its claims could not be validated, check if the expiry time is set correctly.").WithWrap(err).WithDebug(err.Error()))
146138
}
147139

148-
claims := token.Claims
140+
if isJWE && oidcClient.GetTokenEndpointAuthSigningAlgorithm() != "" && oidcClient.GetTokenEndpointAuthSigningAlgorithm() != parsedToken.Headers[0].Algorithm {
141+
return nil, errorsx.WithStack(ErrInvalidClient.WithHintf("The client_assertion uses signing algorithm '%s', but the requested OAuth 2.0 Client enforces signing algorithm '%s'.", parsedToken.Headers[0].Algorithm, oidcClient.GetTokenEndpointAuthSigningAlgorithm()))
142+
}
143+
144+
claims = token.Claims
145+
149146
var jti string
150147
if !claims.VerifyIssuer(clientID, true) {
151148
return nil, errorsx.WithStack(ErrInvalidClient.WithHint("Claim 'iss' from 'client_assertion' must match the 'client_id' of the OAuth 2.0 Client."))

client_authentication_test.go

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,31 @@ import (
3131
"github.com/ory/fosite/storage"
3232
)
3333

34+
func encryptAssertionWithRSAKey(t *testing.T, token string, pubKey *rsa.PublicKey) string {
35+
eo := &jose.EncrypterOptions{}
36+
eo = eo.WithContentType("JWT").WithType("JWT")
37+
enc, err := jose.NewEncrypter(
38+
jose.ContentEncryption("A256GCM"),
39+
jose.Recipient{
40+
Algorithm: jose.KeyAlgorithm("RSA-OAEP"),
41+
Key: pubKey,
42+
KeyID: "enc_key",
43+
},
44+
eo)
45+
46+
require.NoError(t, err, "unable to build encrypter; err=%v", err)
47+
48+
// Encrypt the token
49+
o, err := enc.Encrypt([]byte(token))
50+
require.NoError(t, err, "encrypting the token failed. err=%v", err)
51+
52+
// Serialize the encrypted token
53+
token, err = o.CompactSerialize()
54+
require.NoError(t, err, "serializing the encrypted token failed. err=%v", err)
55+
56+
return token
57+
}
58+
3459
func mustGenerateRSAAssertion(t *testing.T, claims jwt.MapClaims, key *rsa.PrivateKey, kid string) string {
3560
token := jwt.NewWithClaims(jose.RS256, claims)
3661
token.Header["kid"] = kid
@@ -75,13 +100,23 @@ func TestAuthenticateClient(t *testing.T) {
75100
const at = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
76101

77102
hasher := &BCrypt{Config: &Config{HashCost: 6}}
103+
encKey := gen.MustRSAKey()
104+
105+
config := &Config{
106+
JWKSFetcherStrategy: NewDefaultJWKSFetcherStrategy(),
107+
ClientSecretsHasher: hasher,
108+
TokenURL: "token-url",
109+
HTTPClient: retryablehttp.NewClient(),
110+
}
111+
78112
f := &Fosite{
79-
Store: storage.NewMemoryStore(),
80-
Config: &Config{
81-
JWKSFetcherStrategy: NewDefaultJWKSFetcherStrategy(),
82-
ClientSecretsHasher: hasher,
83-
TokenURL: "token-url",
84-
HTTPClient: retryablehttp.NewClient(),
113+
Store: storage.NewMemoryStore(),
114+
Config: config,
115+
JWTHelper: &JWTHelper{
116+
Config: config,
117+
JWTStrategy: jwt.NewDefaultStrategy(func(ctx context.Context, context *jwt.KeyContext) (interface{}, error) {
118+
return encKey, nil
119+
}),
85120
},
86121
}
87122

@@ -300,6 +335,26 @@ func TestAuthenticateClient(t *testing.T) {
300335
}, rsaKey, "kid-foo")}, "client_assertion_type": []string{at}},
301336
r: new(http.Request),
302337
},
338+
{
339+
d: "should pass with proper encrypted RSA assertion when JWKs are set within the client and client_id is set in the request",
340+
client: &DefaultOpenIDConnectClient{DefaultClient: &DefaultClient{ID: "bar", Secret: barSecret}, JSONWebKeys: rsaJwks, TokenEndpointAuthMethod: "private_key_jwt"},
341+
form: url.Values{
342+
"client_id": []string{"bar"},
343+
"client_assertion": {
344+
encryptAssertionWithRSAKey(t,
345+
mustGenerateRSAAssertion(t, jwt.MapClaims{
346+
"sub": "bar",
347+
"exp": time.Now().Add(time.Hour).Unix(),
348+
"iss": "bar",
349+
"jti": "12345",
350+
"aud": "token-url",
351+
}, rsaKey, "kid-foo"),
352+
&encKey.PublicKey),
353+
},
354+
"client_assertion_type": []string{at},
355+
},
356+
r: new(http.Request),
357+
},
303358
{
304359
d: "should pass with proper ECDSA assertion when JWKs are set within the client and client_id is not set in the request",
305360
client: &DefaultOpenIDConnectClient{DefaultClient: &DefaultClient{ID: "bar", Secret: barSecret}, JSONWebKeys: ecdsaJwks, TokenEndpointAuthMethod: "private_key_jwt", TokenEndpointAuthSigningAlgorithm: "ES256"},

fosite.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ type Fosite struct {
142142
Store Storage
143143

144144
Config Configurator
145+
146+
*JWTHelper
145147
}
146148

147149
// GetMinParameterEntropy returns MinParameterEntropy if set. Defaults to fosite.MinParameterEntropy.

0 commit comments

Comments
 (0)