Skip to content

Commit

Permalink
fix: add the OIDC handler for device flow
Browse files Browse the repository at this point in the history
  • Loading branch information
wood-push-melon committed Apr 4, 2024
1 parent 3be7722 commit 120b754
Show file tree
Hide file tree
Showing 11 changed files with 818 additions and 35 deletions.
6 changes: 3 additions & 3 deletions compose/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,18 +82,18 @@ func ComposeAllEnabled(config *fosite.Config, storage interface{}, key interface
OAuth2RefreshTokenGrantFactory,
OAuth2ResourceOwnerPasswordCredentialsFactory,
RFC7523AssertionGrantFactory,
RFC8628DeviceFactory,
RFC8628DeviceAuthorizationTokenFactory,

OpenIDConnectExplicitFactory,
OpenIDConnectImplicitFactory,
OpenIDConnectHybridFactory,
OpenIDConnectRefreshFactory,
OpenIDConnectDeviceFactory,

OAuth2TokenIntrospectionFactory,
OAuth2TokenRevocationFactory,

RFC8628DeviceFactory,
RFC8628DeviceAuthorizationTokenFactory,

OAuth2PKCEFactory,
PushedAuthorizeHandlerFactory,
)
Expand Down
16 changes: 16 additions & 0 deletions compose/compose_openid.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/ory/fosite"
"github.com/ory/fosite/handler/oauth2"
"github.com/ory/fosite/handler/openid"
"github.com/ory/fosite/handler/rfc8628"
"github.com/ory/fosite/token/jwt"
)

Expand Down Expand Up @@ -77,3 +78,18 @@ func OpenIDConnectHybridFactory(config fosite.Configurator, storage interface{},
OpenIDConnectRequestValidator: openid.NewOpenIDConnectRequestValidator(strategy.(jwt.Signer), config),
}
}

// OpenIDConnectDeviceFactory creates an OpenID Connect device ("device code flow") grant handler.
//
// **Important note:** You must add this handler *after* you have added an OAuth2 device authorization handler!
func OpenIDConnectDeviceFactory(config fosite.Configurator, storage interface{}, strategy interface{}) interface{} {
return &openid.OpenIDConnectDeviceHandler{
OpenIDConnectRequestStorage: storage.(openid.OpenIDConnectRequestStorage),
IDTokenHandleHelper: &openid.IDTokenHandleHelper{
IDTokenStrategy: strategy.(openid.OpenIDConnectTokenStrategy),
},
OpenIDConnectRequestValidator: openid.NewOpenIDConnectRequestValidator(strategy.(jwt.Signer), config),
DeviceCodeStrategy: strategy.(rfc8628.DeviceCodeStrategy),
Config: config,
}
}
2 changes: 1 addition & 1 deletion device_request_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (

// NewDeviceRequest parses an http Request returns a Device request
func (f *Fosite) NewDeviceRequest(ctx context.Context, r *http.Request) (_ DeviceRequester, err error) {
ctx, span := trace.SpanFromContext(ctx).TracerProvider().Tracer("github.com/ory/fosite").Start(ctx, "Fosite.NewAccessRequest")
ctx, span := trace.SpanFromContext(ctx).TracerProvider().Tracer("github.com/ory/fosite").Start(ctx, "Fosite.NewDeviceRequest")
defer otelx.End(span, &err)

request := NewDeviceRequest()
Expand Down
52 changes: 52 additions & 0 deletions handler/openid/flow_device_auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package openid

import (
"context"

"github.com/ory/fosite/handler/rfc8628"

"github.com/ory/fosite"
"github.com/ory/x/errorsx"
)

// OpenIDConnectDeviceHandler a response handler for the Device Authorization Grant with OpenID Connect identity layer
type OpenIDConnectDeviceHandler struct {
OpenIDConnectRequestStorage OpenIDConnectRequestStorage
OpenIDConnectRequestValidator *OpenIDConnectRequestValidator
DeviceCodeStrategy rfc8628.DeviceCodeStrategy

Config interface {
fosite.IDTokenLifespanProvider
}

*IDTokenHandleHelper
}

func (c *OpenIDConnectDeviceHandler) HandleDeviceEndpointRequest(ctx context.Context, dar fosite.DeviceRequester, resp fosite.DeviceResponder) error {
if !(dar.GetGrantedScopes().Has("openid")) {
return nil
}

if !dar.GetClient().GetGrantTypes().Has(string(fosite.GrantTypeDeviceCode)) {
return nil
}

if len(resp.GetDeviceCode()) == 0 {
return errorsx.WithStack(fosite.ErrMisconfiguration.WithDebug("The device code has not been issued yet, indicating a broken code configuration."))
}

if err := c.OpenIDConnectRequestValidator.ValidatePrompt(ctx, dar); err != nil {
return err
}

signature, err := c.DeviceCodeStrategy.DeviceCodeSignature(ctx, resp.GetDeviceCode())
if err != nil {
return err
}

if err := c.OpenIDConnectRequestStorage.CreateOpenIDConnectSession(ctx, signature, dar.Sanitize(oidcParameters)); err != nil {
return errorsx.WithStack(fosite.ErrServerError.WithWrap(err).WithDebug(err.Error()))
}

return nil
}
160 changes: 160 additions & 0 deletions handler/openid/flow_device_auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package openid

import (
"context"
"fmt"
"testing"
"time"

"github.com/golang/mock/gomock"
"github.com/ory/fosite/internal"
"github.com/pkg/errors"

"github.com/stretchr/testify/require"

"github.com/coocood/freecache"

"github.com/ory/fosite"
"github.com/ory/fosite/handler/rfc8628"
"github.com/ory/fosite/token/hmac"
"github.com/ory/fosite/token/jwt"
)

func TestDeviceAuth_HandleDeviceEndpointRequest(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
store := internal.NewMockOpenIDConnectRequestStorage(ctrl)

config := &fosite.Config{
MinParameterEntropy: fosite.MinParameterEntropy,
DeviceAndUserCodeLifespan: time.Hour * 24,
}

signer := &jwt.DefaultSigner{
GetPrivateKey: func(ctx context.Context) (interface{}, error) {
return key, nil
},
}

h := OpenIDConnectDeviceHandler{
OpenIDConnectRequestStorage: store,
OpenIDConnectRequestValidator: NewOpenIDConnectRequestValidator(signer, config),
DeviceCodeStrategy: &rfc8628.DefaultDeviceStrategy{
Enigma: &hmac.HMACStrategy{Config: &fosite.Config{GlobalSecret: []byte("foobar")}},
RateLimiterCache: freecache.NewCache(1024 * 1024),
Config: config,
},
Config: config,
IDTokenHandleHelper: &IDTokenHandleHelper{
IDTokenStrategy: &DefaultStrategy{
Signer: signer,
Config: config,
},
},
}

session := &DefaultSession{
Claims: &jwt.IDTokenClaims{
Subject: "foo",
},
Headers: &jwt.Headers{},
}

client := &fosite.DefaultClient{
ID: "foo",
GrantTypes: fosite.Arguments{string(fosite.GrantTypeDeviceCode)},
}

testCases := []struct {
description string
authreq *fosite.DeviceRequest
authresp *fosite.DeviceResponse
setup func(authreq *fosite.DeviceRequest)
expectErr error
}{
{
description: "should ignore because scope openid is not set",
authreq: &fosite.DeviceRequest{
Request: fosite.Request{
GrantedScope: fosite.Arguments{"email"},
},
},
},
{
description: "should ignore because client grant type is invalid",
authreq: &fosite.DeviceRequest{
Request: fosite.Request{
GrantedScope: fosite.Arguments{"openid", "email"},
Client: &fosite.DefaultClient{
GrantTypes: []string{string(fosite.GrantTypeAuthorizationCode)},
},
},
},
},
{
description: "should fail because device code is not issued",
authreq: &fosite.DeviceRequest{
Request: fosite.Request{
GrantedScope: fosite.Arguments{"openid", "email"},
Client: client,
},
},
authresp: &fosite.DeviceResponse{},
expectErr: fosite.ErrMisconfiguration,
},
{
description: "should fail because cannot create session",
authreq: &fosite.DeviceRequest{
Request: fosite.Request{
GrantedScope: fosite.Arguments{"openid", "email"},
Client: client,
Session: session,
},
},
authresp: &fosite.DeviceResponse{
DeviceCode: "device_code",
},
setup: func(authreq *fosite.DeviceRequest) {
store.
EXPECT().
CreateOpenIDConnectSession(gomock.Any(), gomock.Any(), gomock.Eq(authreq.Sanitize(oidcParameters))).
Return(errors.New(""))
},
expectErr: fosite.ErrServerError,
},
{
description: "should pass",
authreq: &fosite.DeviceRequest{
Request: fosite.Request{
GrantedScope: fosite.Arguments{"openid", "email"},
Client: client,
Session: session,
},
},
authresp: &fosite.DeviceResponse{
DeviceCode: "device_code",
},
setup: func(authreq *fosite.DeviceRequest) {
store.
EXPECT().
CreateOpenIDConnectSession(gomock.Any(), gomock.Any(), gomock.Eq(authreq.Sanitize(oidcParameters))).
Return(nil)
},
},
}

for i, testCase := range testCases {
t.Run(fmt.Sprintf("case=%d/description=%s", i, testCase.description), func(t *testing.T) {
if testCase.setup != nil {
testCase.setup(testCase.authreq)
}

err := h.HandleDeviceEndpointRequest(context.Background(), testCase.authreq, testCase.authresp)
if testCase.expectErr != nil {
require.EqualError(t, err, testCase.expectErr.Error(), "%+v", err)
} else {
require.NoError(t, err, "%+v", err)
}
})
}
}
61 changes: 61 additions & 0 deletions handler/openid/flow_device_token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package openid

import (
"context"

"github.com/pkg/errors"

"github.com/ory/fosite"
"github.com/ory/x/errorsx"
)

func (c *OpenIDConnectDeviceHandler) HandleTokenEndpointRequest(ctx context.Context, requester fosite.AccessRequester) error {
return errorsx.WithStack(fosite.ErrUnknownRequest)
}

func (c *OpenIDConnectDeviceHandler) PopulateTokenEndpointResponse(ctx context.Context, requester fosite.AccessRequester, responder fosite.AccessResponder) error {
if !c.CanHandleTokenEndpointRequest(ctx, requester) {
return errorsx.WithStack(fosite.ErrUnknownRequest)
}

deviceCode := requester.GetRequestForm().Get("device_code")
signature, err := c.DeviceCodeStrategy.DeviceCodeSignature(ctx, deviceCode)
ar, err := c.OpenIDConnectRequestStorage.GetOpenIDConnectSession(ctx, signature, requester)
if errors.Is(err, ErrNoSessionFound) {
return errorsx.WithStack(fosite.ErrUnknownRequest.WithWrap(err).WithDebug(err.Error()))
}
if err != nil {
return errorsx.WithStack(fosite.ErrServerError.WithWrap(err).WithDebug(err.Error()))
}

if !ar.GetGrantedScopes().Has("openid") {
return errorsx.WithStack(fosite.ErrMisconfiguration.WithDebug("An OpenID Connect session was found but the openid scope is missing, probably due to a broken code configuration."))
}

if !requester.GetClient().GetGrantTypes().Has(string(fosite.GrantTypeDeviceCode)) {
return errorsx.WithStack(fosite.ErrUnauthorizedClient.WithHint("The OAuth 2.0 Client is not allowed to use the authorization grant \"urn:ietf:params:oauth:grant-type:device_code\"."))
}

session, ok := ar.GetSession().(Session)
if !ok {
return errorsx.WithStack(fosite.ErrServerError.WithDebug("Failed to generate id token because session must be of type fosite/handler/openid.Session."))
}

claims := session.IDTokenClaims()
if claims.Subject == "" {
return errorsx.WithStack(fosite.ErrServerError.WithDebug("Failed to generate id token because subject is an empty string."))
}

claims.AccessTokenHash = c.GetAccessTokenHash(ctx, requester, responder)

idTokenLifespan := fosite.GetEffectiveLifespan(requester.GetClient(), fosite.GrantTypeDeviceCode, fosite.IDToken, c.Config.GetIDTokenLifespan(ctx))
return c.IssueExplicitIDToken(ctx, idTokenLifespan, ar, responder)
}

func (c *OpenIDConnectDeviceHandler) CanSkipClientAuth(ctx context.Context, requester fosite.AccessRequester) bool {
return false
}

func (c *OpenIDConnectDeviceHandler) CanHandleTokenEndpointRequest(ctx context.Context, requester fosite.AccessRequester) bool {
return requester.GetGrantTypes().ExactOne(string(fosite.GrantTypeDeviceCode))
}
Loading

0 comments on commit 120b754

Please sign in to comment.