-
-
Notifications
You must be signed in to change notification settings - Fork 367
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
Implement RFC 8628 #826
base: master
Are you sure you want to change the base?
Implement RFC 8628 #826
Conversation
7589ecc
to
73d570f
Compare
Looks like the conformity tests are failing because it tries to use this version of fosite on hydra |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you very much for this massive PR! There's still thousands of lines to review, but here are some first remarks.
Primarily, some of the diffs are hard to read because they re-organize code into different files. Would it be possible to focus the changes on what's required to change and leave the refactoring out? We can always re-organize the code later, but given the sensitivity of the files touched (core token methods), it will be significantly easier to review those changes. Thank you!
go.mod
Outdated
go 1.20 | ||
go 1.21 | ||
|
||
toolchain go1.21.4 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is 1.21 needed?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It was required for the update of ory/x
, I will roll it back to the lateset version that supports 1.20
.
type DeviceEndpointHandlers []DeviceEndpointHandler | ||
|
||
// Append adds an DeviceEndpointHandlers to this list. Ignores duplicates based on reflect.TypeOf. | ||
func (a *DeviceEndpointHandlers) Append(h DeviceEndpointHandler) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add a test for this? :)
handler/rfc8628/strategy_hmacsha.go
Outdated
key := code + "_limiter" | ||
|
||
keyBytes := []byte(key) | ||
object, err := h.RateLimiterCache.Get(keyBytes) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This won't work in real world scenarios. The cache is in memory which means:
- It won't work if more than one instance (which is almost always the case) of the library/hydra running
- It won't persist across restarts
In my view, rate limiting should be the concern of the firewall/ingress/reverse proxy and I think we could simplify this PR a bit by removing this part or making it optional and/or more generic
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed on all your points. The reason we included this change is to be spec compliant and the reason we didn't implement a more sophisticated solution (which would probably require an external caching system) is that rate limiting should be handled by the ingress/firewall. But looking back at it, it is probably better to leave it unhandled, instead of having partly correct logic.
It is probably better to remove this and leave it as future work.
@@ -5,197 +5,99 @@ package oauth2 | |||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This diff is quite challenging to read, would it be possible to reduce the refactoring and separating into different files? Since this is security relevant code, the smaller the diff the faster the review :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We will try to revert the refactored code and push the changes in the coming days
You should be able to change the target commit in the CI file to target your PR in hydra, which should make the tests pass |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks great already, however due to the security implications I agree with @aeneasr that we should pull out some of the unrelated refactoring into separate PRs to reduce the size and complexity of this one, especially the migration from github.com/golang/mock/gomock to go.uber.org/mock/gomock and maybe also some other code style changes.
I did not yet fully review all parts, but these are some preliminary suggestions already.
device_request_handler.go
Outdated
scope := RemoveEmpty(strings.Split(request.Form.Get("scope"), " ")) | ||
for _, permission := range scope { | ||
if !f.Config.GetScopeStrategy(ctx)(request.Client.GetScopes(), permission) { | ||
return errorsx.WithStack(ErrInvalidScope.WithHintf("The OAuth 2.0 Client is not allowed to request scope '%s'.", permission)) | ||
} | ||
} | ||
request.SetRequestedScopes(scope) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider re-using the scope strategy across loop iterations:
scope := RemoveEmpty(strings.Split(request.Form.Get("scope"), " ")) | |
for _, permission := range scope { | |
if !f.Config.GetScopeStrategy(ctx)(request.Client.GetScopes(), permission) { | |
return errorsx.WithStack(ErrInvalidScope.WithHintf("The OAuth 2.0 Client is not allowed to request scope '%s'.", permission)) | |
} | |
} | |
request.SetRequestedScopes(scope) | |
scopes := RemoveEmpty(strings.Split(request.Form.Get("scope"), " ")) | |
scopeStrategy := f.Config.GetScopeStrategy(ctx) | |
for _, scope := range scopes { | |
if !scopeStrategy(request.Client.GetScopes(), scope) { | |
return errorsx.WithStack(ErrInvalidScope.WithHintf("The OAuth 2.0 Client is not allowed to request scope '%s'.", scope)) | |
} | |
} | |
request.SetRequestedScopes(scope) |
device_request_handler_test.go
Outdated
ar, err := fosite.NewDeviceRequest(context.Background(), r) | ||
if c.expectedError != nil { | ||
assert.EqualError(t, err, c.expectedError.Error()) | ||
} else { | ||
require.NoError(t, err) | ||
assert.NotNil(t, ar.GetRequestedAt()) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reduce logic in tests:
ar, err := fosite.NewDeviceRequest(context.Background(), r) | |
if c.expectedError != nil { | |
assert.EqualError(t, err, c.expectedError.Error()) | |
} else { | |
require.NoError(t, err) | |
assert.NotNil(t, ar.GetRequestedAt()) | |
} | |
ar, err := fosite.NewDeviceRequest(context.Background(), r) | |
require.ErrorIs(t, err, c.expectedError) | |
if c.expectedError == nil { | |
assert.NotNil(t, ar.GetRequestedAt()) | |
} |
device_request_handler_test.go
Outdated
assert.EqualError(t, err, c.expectedError.Error()) | ||
} else { | ||
require.NoError(t, err) | ||
assert.NotNil(t, req.GetRequestedAt()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
req.GetRequestedAt()
returns a time.Time
. This can never be nil.
assert.NotNil(t, req.GetRequestedAt()) | |
assert.NotZero(t, req.GetRequestedAt()) |
device_request_test.go
Outdated
func TestDeviceRequest(t *testing.T) { | ||
r := NewDeviceRequest() | ||
r.Client = &DefaultClient{} | ||
r.SetRequestedScopes([]string{"17", "42"}) | ||
assert.True(t, r.GetRequestedScopes().Has("17", "42")) | ||
assert.Equal(t, r.Client, r.GetClient()) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is not testing the DeviceRequest
, but the Request
. The test adds no value, as DeviceRequest
has no logic to be tested so IMO this can be removed.
device_response.go
Outdated
// FromJson populates a response's fields from a json | ||
func (d *DeviceResponse) FromJson(r io.Reader) error { | ||
return json.NewDecoder(r).Decode(&d) | ||
} | ||
|
||
// ToJson writes a response as a json | ||
func (d *DeviceResponse) ToJson(rw io.Writer) error { | ||
return json.NewEncoder(rw).Encode(&d) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should implement the json.Marshaler
and json.Unmarshaler
interfaces instead. As there is no specific logic implemented here, the methods can just be removed.
errors.go
Outdated
ErrPollingRateLimited = &RFC6749Error{ | ||
DescriptionField: "The authorization request was rate-limited to prevent system overload.", | ||
HintField: "Ensure that you don't call the token endpoint sooner than the polling interval", | ||
ErrorField: errPollingIntervalRateLimited, | ||
CodeField: http.StatusTooManyRequests, | ||
} | ||
ErrDeviceExpiredToken = &RFC6749Error{ | ||
DescriptionField: "The device_code has expired, and the device authorization session has concluded.", | ||
ErrorField: errDeviceExpiredToken, | ||
CodeField: http.StatusBadRequest, | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suggest to keep the exact same naming for the errors as in the RFC to make it easier to understand.
ErrPollingRateLimited = &RFC6749Error{ | |
DescriptionField: "The authorization request was rate-limited to prevent system overload.", | |
HintField: "Ensure that you don't call the token endpoint sooner than the polling interval", | |
ErrorField: errPollingIntervalRateLimited, | |
CodeField: http.StatusTooManyRequests, | |
} | |
ErrDeviceExpiredToken = &RFC6749Error{ | |
DescriptionField: "The device_code has expired, and the device authorization session has concluded.", | |
ErrorField: errDeviceExpiredToken, | |
CodeField: http.StatusBadRequest, | |
} | |
ErrSlowDown = &RFC6749Error{ | |
DescriptionField: "The authorization request was rate-limited to prevent system overload.", | |
HintField: "Ensure that you don't call the token endpoint sooner than the polling interval", | |
ErrorField: errSlowDown, | |
CodeField: http.StatusBadRequest, // http.StatusTooManyRequests makes semantically more sense but it is not specified in the RFC (therefore the default StatusBadRequest should be used) | |
} | |
ErrDeviceExpiredToken = &RFC6749Error{ | |
DescriptionField: "The device_code has expired, and the device authorization session has concluded.", | |
ErrorField: errDeviceExpiredToken, | |
CodeField: http.StatusBadRequest, | |
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am wondering whether the split up is actually required. Can't we just do some internal refactoring to reuse the code, instead of this breaking change?
Regardless, I agree with @aeneasr that this should happen in a separate PR.
handler/rfc8628/strategy_hmacsha.go
Outdated
|
||
// GenerateUserCode generates a user_code | ||
func (h *DefaultDeviceStrategy) GenerateUserCode(ctx context.Context) (string, string, error) { | ||
seq, err := randx.RuneSequence(8, []rune(randx.AlphaUpper)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The RFC discusses extensively the accessibility of user codes wrt length and character sets. As this is "just" the default implementation, these defaults are probably fine.
However, I think that it would make sense to make length and character set configurable, just so fosite users don't have to fork the strategy just to adjust the character set.
} | ||
|
||
// CodeHandler handles authorization/device code related operations. | ||
type CodeHandler interface { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As the device auth code grant has two codes (user & device), the naming should be clear at any point which one is used. Here I'm struggling really to understand which one it is.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I strongly agree with this, I've been trying to follow along with an implementation (outside of Hydra), and it's been extremely unclear what the correct steps to handle user_code's are.
24db6b9
to
aa568e6
Compare
Thank you for the reviews. I reverted the refactor on the oauth2 handlers and tried to address all the comments. Also pinned the ci to use hydra from the PR branch and now the tests pass. Please have another go. |
aa568e6
to
bf057ec
Compare
device_write_test.go
Outdated
resp.SetUserCode("AAAA") | ||
resp.SetDeviceCode("BBBB") | ||
resp.SetInterval(int( | ||
oauth2.Config.GetDeviceAuthTokenPollingInterval(context.TODO()).Round(time.Second).Seconds(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd suggest to add a simple
ctx := context.Background()
in the beginning of each test, and use that as the context everywhere.
device_write_test.go
Outdated
oauth2.Config.GetDeviceAuthTokenPollingInterval(context.TODO()).Round(time.Second).Seconds(), | ||
)) | ||
resp.SetExpiresIn(int64( | ||
time.Now().Round(time.Second).Add(oauth2.Config.GetDeviceAndUserCodeLifespan(context.TODO())).Second(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
time.Now().Round(time.Second).Add(oauth2.Config.GetDeviceAndUserCodeLifespan(context.TODO())).Second(), | |
oauth2.Config.GetDeviceAndUserCodeLifespan(ctx), |
bf057ec
to
35c89bf
Compare
35c89bf
to
bfa2f4e
Compare
The WriteAccessError is used to construct error responses as described in Section 5.2 of [RFC6749]. It is not limited to access token responses. Perhaps we should rename the function to Rfc6749TokenError.
feat: add the access token endpoint handling for device authorization flow
… auth/device code is expired
fix: add the OIDC handler for device flow
* fix: fix the refresh token issue * fix: fix the OIDC token missing issue
bfa2f4e
to
904e20c
Compare
904e20c
to
e5035e8
Compare
e5035e8
to
94653ee
Compare
07bf8f6
to
9753bcd
Compare
c778674
to
ae6e4e3
Compare
Related Design Document
Implements RFC 8628.
Checklist
If this pull request addresses a security vulnerability,
I confirm that I got approval (please contact [email protected]) from the maintainers to push the changes.
Further comments
This PR is based on the work done on #701, by @supercairos and @BuzzBumbleBee. That PR was based on an older version of fosite and was missing some features/tests.
Comments:
go.uber.org/mock/gomock
(github.com/golang/mock/gomock
is deprecated)AuthorizeExplicitGrantHandler
to generalize it to reduce code duplication