Skip to content
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

Open
wants to merge 30 commits into
base: master
Choose a base branch
from
Open

Implement RFC 8628 #826

wants to merge 30 commits into from

Conversation

nsklikas
Copy link

@nsklikas nsklikas commented Sep 30, 2024

BREAKING CHANGES: This patch breaks up `OAuth2AuthorizeExplicitFactory` into
`OAuth2AuthorizeExplicitAuthFactory` and `Oauth2AuthorizeExplicitTokenFactory`

Related Design Document

Implements RFC 8628.

Checklist

  • I have read the contributing guidelines and signed the CLA.
  • I have referenced an issue containing the design document if my change introduces a new feature.
  • I have read the security policy.
  • I confirm that this pull request does not address a security vulnerability.
    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.
  • I have added tests that prove my fix is effective or that my feature works.
  • I have added the necessary documentation within the code base (if appropriate).

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:

  • Due to some dependency issues we had to switch to go.uber.org/mock/gomock (github.com/golang/mock/gomock is deprecated)
  • We had to do a small refactor of the AuthorizeExplicitGrantHandler to generalize it to reduce code duplication

@CLAassistant
Copy link

CLAassistant commented Sep 30, 2024

CLA assistant check
All committers have signed the CLA.

@nsklikas
Copy link
Author

Looks like the conformity tests are failing because it tries to use this version of fosite on hydra master. But they pass on the hydra PR with this version of fosite. Changes are needed on the hydra config DefaultProvider object, how should we fix this?

Copy link
Member

@aeneasr aeneasr left a 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is 1.21 needed?

Copy link
Author

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) {
Copy link
Member

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? :)

key := code + "_limiter"

keyBytes := []byte(key)
object, err := h.RateLimiterCache.Get(keyBytes)
Copy link
Member

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:

  1. It won't work if more than one instance (which is almost always the case) of the library/hydra running
  2. 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

Copy link
Author

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

Copy link
Member

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 :)

Copy link
Author

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

@aeneasr
Copy link
Member

aeneasr commented Oct 9, 2024

Looks like the conformity tests are failing because it tries to use this version of fosite on hydra master. But they pass on the hydra PR with this version of fosite. Changes are needed on the hydra config DefaultProvider object, how should we fix this?

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

Copy link
Member

@zepatrik zepatrik left a 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 Show resolved Hide resolved
Comment on lines 62 to 68
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)
Copy link
Member

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:

Suggested change
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.go Show resolved Hide resolved
Comment on lines 125 to 126
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())
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reduce logic in tests:

Suggested change
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 Show resolved Hide resolved
assert.EqualError(t, err, c.expectedError.Error())
} else {
require.NoError(t, err)
assert.NotNil(t, req.GetRequestedAt())
Copy link
Member

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.

Suggested change
assert.NotNil(t, req.GetRequestedAt())
assert.NotZero(t, req.GetRequestedAt())

Comment on lines 12 to 18
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())
}
Copy link
Member

@zepatrik zepatrik Oct 11, 2024

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.

Comment on lines 98 to 106
// 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)
}
Copy link
Member

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
Comment on lines 212 to 222
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,
}
Copy link
Member

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.

Suggested change
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,
}

Copy link
Member

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.


// GenerateUserCode generates a user_code
func (h *DefaultDeviceStrategy) GenerateUserCode(ctx context.Context) (string, string, error) {
seq, err := randx.RuneSequence(8, []rune(randx.AlphaUpper))
Copy link
Member

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 {
Copy link
Member

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.

Copy link

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.

@nsklikas nsklikas force-pushed the canonical-master branch 3 times, most recently from 24db6b9 to aa568e6 Compare October 18, 2024 10:09
@nsklikas
Copy link
Author

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.

resp.SetUserCode("AAAA")
resp.SetDeviceCode("BBBB")
resp.SetInterval(int(
oauth2.Config.GetDeviceAuthTokenPollingInterval(context.TODO()).Round(time.Second).Seconds(),
Copy link
Member

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.

oauth2.Config.GetDeviceAuthTokenPollingInterval(context.TODO()).Round(time.Second).Seconds(),
))
resp.SetExpiresIn(int64(
time.Now().Round(time.Second).Add(oauth2.Config.GetDeviceAndUserCodeLifespan(context.TODO())).Second(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
time.Now().Round(time.Second).Add(oauth2.Config.GetDeviceAndUserCodeLifespan(context.TODO())).Second(),
oauth2.Config.GetDeviceAndUserCodeLifespan(ctx),

handler/rfc8628/auth_handler.go Show resolved Hide resolved
handler/rfc8628/storage.go Outdated Show resolved Hide resolved
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants