Skip to content

Commit 9720241

Browse files
aeneasrVladimir Kaluginseliverstov-tinkoff
authored
feat: add support for urn:ietf:params:oauth:grant-type:jwt-bearer grant type RFC 7523� (#560)
Closes #546 Closes #305 Co-authored-by: Vladimir Kalugin <v.p.kalugin@tinkoff.ru> Co-authored-by: i.seliverstov <i.seliverstov@tinkoff.ru>
1 parent 173d60e commit 9720241

36 files changed

Lines changed: 3399 additions & 82 deletions

.circleci/config.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ version: 2.1
33
orbs:
44
changelog: ory/changelog@0.1.4
55
nancy: ory/nancy@0.0.13
6-
golangci: ory/golangci@0.0.9
6+
golangci: ory/golangci@0.0.11
77

88
jobs:
99
test:

access_request_handler.go

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ import (
5757
// client MUST authenticate with the authorization server as described
5858
// in Section 3.2.1.
5959
func (f *Fosite) NewAccessRequest(ctx context.Context, r *http.Request, session Session) (AccessRequester, error) {
60-
var err error
6160
accessRequest := NewAccessRequest(session)
6261

6362
if r.Method != "POST" {
@@ -80,18 +79,34 @@ func (f *Fosite) NewAccessRequest(ctx context.Context, r *http.Request, session
8079
return accessRequest, errorsx.WithStack(ErrInvalidRequest.WithHint("Request parameter 'grant_type' is missing"))
8180
}
8281

83-
client, err := f.AuthenticateClient(ctx, r, r.PostForm)
84-
if err != nil {
85-
return accessRequest, err
82+
client, clientErr := f.AuthenticateClient(ctx, r, r.PostForm)
83+
if clientErr == nil {
84+
accessRequest.Client = client
8685
}
87-
accessRequest.Client = client
8886

8987
var found = false
9088
for _, loader := range f.TokenEndpointHandlers {
89+
// Is the loader responsible for handling the request?
90+
if !loader.CanHandleTokenEndpointRequest(accessRequest) {
91+
continue
92+
}
93+
94+
// The handler **is** responsible!
95+
96+
// Is the client supplied in the request? If not can this handler skip client auth?
97+
if !loader.CanSkipClientAuth(accessRequest) && clientErr != nil {
98+
// No client and handler can not skip client auth -> error.
99+
return accessRequest, clientErr
100+
}
101+
102+
// All good.
91103
if err := loader.HandleTokenEndpointRequest(ctx, accessRequest); err == nil {
92104
found = true
93105
} else if errors.Is(err, ErrUnknownRequest) {
94-
// do nothing
106+
// This is a duplicate because it should already have been handled by
107+
// `loader.CanHandleTokenEndpointRequest(accessRequest)` but let's keep it for sanity.
108+
//
109+
continue
95110
} else if err != nil {
96111
return accessRequest, err
97112
}

access_request_handler_test.go

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ func TestNewAccessRequest(t *testing.T) {
4242
ctrl := gomock.NewController(t)
4343
store := internal.NewMockStorage(ctrl)
4444
handler := internal.NewMockTokenEndpointHandler(ctrl)
45+
handler.EXPECT().CanHandleTokenEndpointRequest(gomock.Any()).Return(true).AnyTimes()
46+
handler.EXPECT().CanSkipClientAuth(gomock.Any()).Return(false).AnyTimes()
4547
hasher := internal.NewMockHasher(ctrl)
4648
defer ctrl.Finish()
4749

@@ -94,6 +96,7 @@ func TestNewAccessRequest(t *testing.T) {
9496
mock: func() {
9597
store.EXPECT().GetClient(gomock.Any(), gomock.Eq("foo")).Return(nil, errors.New(""))
9698
},
99+
handlers: TokenEndpointHandlers{handler},
97100
},
98101
{
99102
header: http.Header{
@@ -118,6 +121,7 @@ func TestNewAccessRequest(t *testing.T) {
118121
mock: func() {
119122
store.EXPECT().GetClient(gomock.Any(), gomock.Eq("foo")).Return(nil, errors.New(""))
120123
},
124+
handlers: TokenEndpointHandlers{handler},
121125
},
122126
{
123127
header: http.Header{
@@ -134,6 +138,7 @@ func TestNewAccessRequest(t *testing.T) {
134138
client.Secret = []byte("foo")
135139
hasher.EXPECT().Compare(context.TODO(), gomock.Eq([]byte("foo")), gomock.Eq([]byte("bar"))).Return(errors.New(""))
136140
},
141+
handlers: TokenEndpointHandlers{handler},
137142
},
138143
{
139144
header: http.Header{
@@ -221,6 +226,239 @@ func TestNewAccessRequest(t *testing.T) {
221226
}
222227
}
223228

229+
func TestNewAccessRequestWithoutClientAuth(t *testing.T) {
230+
ctrl := gomock.NewController(t)
231+
store := internal.NewMockStorage(ctrl)
232+
handler := internal.NewMockTokenEndpointHandler(ctrl)
233+
handler.EXPECT().CanHandleTokenEndpointRequest(gomock.Any()).Return(true).AnyTimes()
234+
handler.EXPECT().CanSkipClientAuth(gomock.Any()).Return(true).AnyTimes()
235+
hasher := internal.NewMockHasher(ctrl)
236+
defer ctrl.Finish()
237+
238+
client := &DefaultClient{}
239+
anotherClient := &DefaultClient{ID: "another"}
240+
fosite := &Fosite{Store: store, Hasher: hasher, AudienceMatchingStrategy: DefaultAudienceMatchingStrategy}
241+
for k, c := range []struct {
242+
header http.Header
243+
form url.Values
244+
mock func()
245+
method string
246+
expectErr error
247+
expect *AccessRequest
248+
handlers TokenEndpointHandlers
249+
}{
250+
// No grant type -> error
251+
{
252+
form: url.Values{},
253+
mock: func() {
254+
store.EXPECT().GetClient(gomock.Any(), gomock.Any()).Times(0)
255+
},
256+
method: "POST",
257+
expectErr: ErrInvalidRequest,
258+
},
259+
// No registered handlers -> error
260+
{
261+
form: url.Values{
262+
"grant_type": {"foo"},
263+
},
264+
mock: func() {
265+
store.EXPECT().GetClient(gomock.Any(), gomock.Any()).Times(0)
266+
},
267+
method: "POST",
268+
expectErr: ErrInvalidRequest,
269+
handlers: TokenEndpointHandlers{},
270+
},
271+
// Handler can skip client auth and ignores missing client.
272+
{
273+
header: http.Header{
274+
"Authorization": {basicAuth("foo", "bar")},
275+
},
276+
form: url.Values{
277+
"grant_type": {"foo"},
278+
},
279+
mock: func() {
280+
// despite error from storage, we should success, because client auth is not required
281+
store.EXPECT().GetClient(gomock.Any(), "foo").Return(nil, errors.New("no client")).Times(1)
282+
handler.EXPECT().HandleTokenEndpointRequest(gomock.Any(), gomock.Any()).Return(nil)
283+
},
284+
method: "POST",
285+
expect: &AccessRequest{
286+
GrantTypes: Arguments{"foo"},
287+
Request: Request{
288+
Client: client,
289+
},
290+
},
291+
handlers: TokenEndpointHandlers{handler},
292+
},
293+
// Should pass if no auth is set in the header and can skip!
294+
{
295+
form: url.Values{
296+
"grant_type": {"foo"},
297+
},
298+
mock: func() {
299+
handler.EXPECT().HandleTokenEndpointRequest(gomock.Any(), gomock.Any()).Return(nil)
300+
},
301+
method: "POST",
302+
expect: &AccessRequest{
303+
GrantTypes: Arguments{"foo"},
304+
Request: Request{
305+
Client: client,
306+
},
307+
},
308+
handlers: TokenEndpointHandlers{handler},
309+
},
310+
// Should also pass if client auth is set!
311+
{
312+
header: http.Header{
313+
"Authorization": {basicAuth("foo", "bar")},
314+
},
315+
form: url.Values{
316+
"grant_type": {"foo"},
317+
},
318+
mock: func() {
319+
store.EXPECT().GetClient(gomock.Any(), "foo").Return(anotherClient, nil).Times(1)
320+
hasher.EXPECT().Compare(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).Times(1)
321+
handler.EXPECT().HandleTokenEndpointRequest(gomock.Any(), gomock.Any()).Return(nil)
322+
},
323+
method: "POST",
324+
expect: &AccessRequest{
325+
GrantTypes: Arguments{"foo"},
326+
Request: Request{
327+
Client: anotherClient,
328+
},
329+
},
330+
handlers: TokenEndpointHandlers{handler},
331+
},
332+
} {
333+
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
334+
r := &http.Request{
335+
Header: c.header,
336+
PostForm: c.form,
337+
Form: c.form,
338+
Method: c.method,
339+
}
340+
c.mock()
341+
ctx := NewContext()
342+
fosite.TokenEndpointHandlers = c.handlers
343+
ar, err := fosite.NewAccessRequest(ctx, r, new(DefaultSession))
344+
345+
if c.expectErr != nil {
346+
assert.EqualError(t, err, c.expectErr.Error())
347+
} else {
348+
require.NoError(t, err)
349+
AssertObjectKeysEqual(t, c.expect, ar, "GrantTypes", "Client")
350+
assert.NotNil(t, ar.GetRequestedAt())
351+
}
352+
})
353+
}
354+
}
355+
356+
// In this test case one handler requires client auth and another handler not.
357+
func TestNewAccessRequestWithMixedClientAuth(t *testing.T) {
358+
ctrl := gomock.NewController(t)
359+
store := internal.NewMockStorage(ctrl)
360+
361+
handlerWithClientAuth := internal.NewMockTokenEndpointHandler(ctrl)
362+
handlerWithClientAuth.EXPECT().CanHandleTokenEndpointRequest(gomock.Any()).Return(true).AnyTimes()
363+
handlerWithClientAuth.EXPECT().CanSkipClientAuth(gomock.Any()).Return(false).AnyTimes()
364+
365+
handlerWithoutClientAuth := internal.NewMockTokenEndpointHandler(ctrl)
366+
handlerWithoutClientAuth.EXPECT().CanHandleTokenEndpointRequest(gomock.Any()).Return(true).AnyTimes()
367+
handlerWithoutClientAuth.EXPECT().CanSkipClientAuth(gomock.Any()).Return(true).AnyTimes()
368+
369+
hasher := internal.NewMockHasher(ctrl)
370+
defer ctrl.Finish()
371+
372+
client := &DefaultClient{}
373+
fosite := &Fosite{Store: store, Hasher: hasher, AudienceMatchingStrategy: DefaultAudienceMatchingStrategy}
374+
for k, c := range []struct {
375+
header http.Header
376+
form url.Values
377+
mock func()
378+
method string
379+
expectErr error
380+
expect *AccessRequest
381+
handlers TokenEndpointHandlers
382+
}{
383+
{
384+
header: http.Header{
385+
"Authorization": {basicAuth("foo", "bar")},
386+
},
387+
form: url.Values{
388+
"grant_type": {"foo"},
389+
},
390+
mock: func() {
391+
store.EXPECT().GetClient(gomock.Any(), gomock.Eq("foo")).Return(client, nil)
392+
client.Public = false
393+
client.Secret = []byte("foo")
394+
hasher.EXPECT().Compare(context.TODO(), gomock.Eq([]byte("foo")), gomock.Eq([]byte("bar"))).Return(errors.New("hash err"))
395+
handlerWithoutClientAuth.EXPECT().HandleTokenEndpointRequest(gomock.Any(), gomock.Any()).Return(nil)
396+
},
397+
method: "POST",
398+
expectErr: ErrInvalidClient,
399+
handlers: TokenEndpointHandlers{handlerWithoutClientAuth, handlerWithClientAuth},
400+
},
401+
{
402+
header: http.Header{
403+
"Authorization": {basicAuth("foo", "bar")},
404+
},
405+
form: url.Values{
406+
"grant_type": {"foo"},
407+
},
408+
mock: func() {
409+
store.EXPECT().GetClient(gomock.Any(), gomock.Eq("foo")).Return(client, nil)
410+
client.Public = false
411+
client.Secret = []byte("foo")
412+
hasher.EXPECT().Compare(context.TODO(), gomock.Eq([]byte("foo")), gomock.Eq([]byte("bar"))).Return(nil)
413+
handlerWithoutClientAuth.EXPECT().HandleTokenEndpointRequest(gomock.Any(), gomock.Any()).Return(nil)
414+
handlerWithClientAuth.EXPECT().HandleTokenEndpointRequest(gomock.Any(), gomock.Any()).Return(nil)
415+
},
416+
method: "POST",
417+
expect: &AccessRequest{
418+
GrantTypes: Arguments{"foo"},
419+
Request: Request{
420+
Client: client,
421+
},
422+
},
423+
handlers: TokenEndpointHandlers{handlerWithoutClientAuth, handlerWithClientAuth},
424+
},
425+
{
426+
header: http.Header{},
427+
form: url.Values{
428+
"grant_type": {"foo"},
429+
},
430+
mock: func() {
431+
store.EXPECT().GetClient(gomock.Any(), gomock.Any()).Times(0)
432+
handlerWithoutClientAuth.EXPECT().HandleTokenEndpointRequest(gomock.Any(), gomock.Any()).Return(nil)
433+
},
434+
method: "POST",
435+
expectErr: ErrInvalidRequest,
436+
handlers: TokenEndpointHandlers{handlerWithoutClientAuth, handlerWithClientAuth},
437+
},
438+
} {
439+
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
440+
r := &http.Request{
441+
Header: c.header,
442+
PostForm: c.form,
443+
Form: c.form,
444+
Method: c.method,
445+
}
446+
c.mock()
447+
ctx := NewContext()
448+
fosite.TokenEndpointHandlers = c.handlers
449+
ar, err := fosite.NewAccessRequest(ctx, r, new(DefaultSession))
450+
451+
if c.expectErr != nil {
452+
assert.EqualError(t, err, c.expectErr.Error())
453+
} else {
454+
require.NoError(t, err)
455+
AssertObjectKeysEqual(t, c.expect, ar, "GrantTypes", "Client")
456+
assert.NotNil(t, ar.GetRequestedAt())
457+
}
458+
})
459+
}
460+
}
461+
224462
func basicAuth(username, password string) string {
225463
return "Basic " + base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", username, password)))
226464
}

compose/compose.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ func ComposeAllEnabled(config *Config, storage interface{}, secret []byte, key *
111111
OAuth2ClientCredentialsGrantFactory,
112112
OAuth2RefreshTokenGrantFactory,
113113
OAuth2ResourceOwnerPasswordCredentialsFactory,
114+
RFC7523AssertionGrantFactory,
114115

115116
OpenIDConnectExplicitFactory,
116117
OpenIDConnectImplicitFactory,

compose/compose_rfc7523.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package compose
2+
3+
import (
4+
"github.com/ory/fosite/handler/oauth2"
5+
"github.com/ory/fosite/handler/rfc7523"
6+
)
7+
8+
// RFC7523AssertionGrantFactory creates an OAuth2 Authorize JWT Grant (using JWTs as Authorization Grants) handler
9+
// and registers an access token, refresh token and authorize code validator.
10+
func RFC7523AssertionGrantFactory(config *Config, storage interface{}, strategy interface{}) interface{} {
11+
return &rfc7523.Handler{
12+
Storage: storage.(rfc7523.RFC7523KeyStorage),
13+
ScopeStrategy: config.GetScopeStrategy(),
14+
AudienceMatchingStrategy: config.GetAudienceStrategy(),
15+
TokenURL: config.TokenURL,
16+
SkipClientAuth: config.GrantTypeJWTBearerCanSkipClientAuth,
17+
JWTIDOptional: config.GrantTypeJWTBearerIDOptional,
18+
JWTIssuedDateOptional: config.GrantTypeJWTBearerIssuedDateOptional,
19+
JWTMaxDuration: config.GetJWTMaxDuration(),
20+
HandleHelper: &oauth2.HandleHelper{
21+
AccessTokenStrategy: strategy.(oauth2.AccessTokenStrategy),
22+
AccessTokenStorage: storage.(oauth2.AccessTokenStorage),
23+
AccessTokenLifespan: config.GetAccessTokenLifespan(),
24+
},
25+
}
26+
}

compose/config.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,18 @@ type Config struct {
9999
// UseLegacyErrorFormat controls whether the legacy error format (with `error_debug`, `error_hint`, ...)
100100
// should be used or not.
101101
UseLegacyErrorFormat bool
102+
103+
// GrantTypeJWTBearerCanSkipClientAuth indicates, if client authentication can be skipped, when using jwt as assertion.
104+
GrantTypeJWTBearerCanSkipClientAuth bool
105+
106+
// GrantTypeJWTBearerIDOptional indicates, if jti (JWT ID) claim required or not in JWT.
107+
GrantTypeJWTBearerIDOptional bool
108+
109+
// GrantTypeJWTBearerIssuedDateOptional indicates, if "iat" (issued at) claim required or not in JWT.
110+
GrantTypeJWTBearerIssuedDateOptional bool
111+
112+
// GrantTypeJWTBearerMaxDuration sets the maximum time after JWT issued date, during which the JWT is considered valid.
113+
GrantTypeJWTBearerMaxDuration time.Duration
102114
}
103115

104116
// GetScopeStrategy returns the scope strategy to be used. Defaults to glob scope strategy.
@@ -198,3 +210,14 @@ func (c *Config) GetMinParameterEntropy() int {
198210
return c.MinParameterEntropy
199211
}
200212
}
213+
214+
// GetJWTMaxDuration specified the maximum amount of allowed `exp` time for a JWT. It compares
215+
// the time with the JWT's `exp` time if the JWT time is larger, will cause the JWT to be invalid.
216+
//
217+
// Defaults to a day.
218+
func (c *Config) GetJWTMaxDuration() time.Duration {
219+
if c.GrantTypeJWTBearerMaxDuration == 0 {
220+
return time.Hour * 24
221+
}
222+
return c.GrantTypeJWTBearerMaxDuration
223+
}

0 commit comments

Comments
 (0)