diff --git a/compose/compose_userinfo_vc.go b/compose/compose_userinfo_vc.go new file mode 100644 index 000000000..da01b869e --- /dev/null +++ b/compose/compose_userinfo_vc.go @@ -0,0 +1,18 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package compose + +import ( + "github.com/ory/fosite" + "github.com/ory/fosite/handler/verifiable" +) + +// OIDCUserinfoVerifiableCredentialFactory creates a verifiable credentials +// handler. +func OIDCUserinfoVerifiableCredentialFactory(config fosite.Configurator, storage, strategy any) any { + return &verifiable.Handler{ + NonceManager: storage.(verifiable.NonceManager), + Config: config, + } +} diff --git a/config.go b/config.go index 1b50eb70c..aeadead7c 100644 --- a/config.go +++ b/config.go @@ -34,6 +34,12 @@ type AccessTokenLifespanProvider interface { GetAccessTokenLifespan(ctx context.Context) time.Duration } +// VerifiableCredentialsNonceLifespanProvider returns the provider for configuring the access token lifespan. +type VerifiableCredentialsNonceLifespanProvider interface { + // GetNonceLifespan returns the nonce lifespan. + GetVerifiableCredentialsNonceLifespan(ctx context.Context) time.Duration +} + // IDTokenLifespanProvider returns the provider for configuring the ID token lifespan. type IDTokenLifespanProvider interface { // GetIDTokenLifespan returns the ID token lifespan. diff --git a/config_default.go b/config_default.go index 7f2e2487e..8284e1cc9 100644 --- a/config_default.go +++ b/config_default.go @@ -68,6 +68,9 @@ type Config struct { // AccessTokenLifespan sets how long an access token is going to be valid. Defaults to one hour. AccessTokenLifespan time.Duration + // VerifiableCredentialsNonceLifespan sets how long a verifiable credentials nonce is going to be valid. Defaults to one hour. + VerifiableCredentialsNonceLifespan time.Duration + // RefreshTokenLifespan sets how long a refresh token is going to be valid. Defaults to 30 days. Set to -1 for // refresh tokens that never expire. RefreshTokenLifespan time.Duration @@ -360,7 +363,7 @@ func (c *Config) GetAuthorizeCodeLifespan(_ context.Context) time.Duration { return c.AuthorizeCodeLifespan } -// GeIDTokenLifespan returns how long an id token should be valid. Defaults to one hour. +// GetIDTokenLifespan returns how long an id token should be valid. Defaults to one hour. func (c *Config) GetIDTokenLifespan(_ context.Context) time.Duration { if c.IDTokenLifespan == 0 { return time.Hour @@ -376,6 +379,14 @@ func (c *Config) GetAccessTokenLifespan(_ context.Context) time.Duration { return c.AccessTokenLifespan } +// GetNonceLifespan returns how long a nonce should be valid. Defaults to one hour. +func (c *Config) GetVerifiableCredentialsNonceLifespan(_ context.Context) time.Duration { + if c.VerifiableCredentialsNonceLifespan == 0 { + return time.Hour + } + return c.VerifiableCredentialsNonceLifespan +} + // GetRefreshTokenLifespan sets how long a refresh token is going to be valid. Defaults to 30 days. Set to -1 for // refresh tokens that never expire. func (c *Config) GetRefreshTokenLifespan(_ context.Context) time.Duration { @@ -385,7 +396,7 @@ func (c *Config) GetRefreshTokenLifespan(_ context.Context) time.Duration { return c.RefreshTokenLifespan } -// GetHashCost returns the bcrypt cost factor. Defaults to 12. +// GetBCryptCost returns the bcrypt cost factor. Defaults to 12. func (c *Config) GetBCryptCost(_ context.Context) int { if c.HashCost == 0 { return DefaultBCryptWorkFactor diff --git a/fosite.go b/fosite.go index 445213629..e84c964e2 100644 --- a/fosite.go +++ b/fosite.go @@ -106,6 +106,7 @@ type Configurator interface { RefreshTokenScopesProvider AccessTokenLifespanProvider RefreshTokenLifespanProvider + VerifiableCredentialsNonceLifespanProvider AuthorizeCodeLifespanProvider TokenEntropyProvider RotatedGlobalSecretsProvider diff --git a/go.mod b/go.mod index addedc4ba..5235109d3 100644 --- a/go.mod +++ b/go.mod @@ -71,4 +71,4 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -go 1.17 +go 1.20 diff --git a/handler/verifiable/handler.go b/handler/verifiable/handler.go new file mode 100644 index 000000000..f1bbae7c6 --- /dev/null +++ b/handler/verifiable/handler.go @@ -0,0 +1,68 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package verifiable + +import ( + "context" + "time" + + "github.com/ory/fosite" + "github.com/ory/x/errorsx" +) + +const ( + draftScope = "userinfo_credential_draft_00" + draftNonceField = "c_nonce_draft_00" + draftNonceExpField = "c_nonce_expires_in_draft_00" +) + +type Handler struct { + Config interface { + fosite.VerifiableCredentialsNonceLifespanProvider + } + NonceManager +} + +var _ fosite.TokenEndpointHandler = (*Handler)(nil) + +func (c *Handler) HandleTokenEndpointRequest(ctx context.Context, request fosite.AccessRequester) error { + if !c.CanHandleTokenEndpointRequest(ctx, request) { + return errorsx.WithStack(fosite.ErrUnknownRequest) + } + + if !request.GetGrantedScopes().Has("openid") { + return errorsx.WithStack(fosite.ErrUnknownRequest) + } + + return nil +} + +func (c *Handler) PopulateTokenEndpointResponse( + ctx context.Context, + request fosite.AccessRequester, + response fosite.AccessResponder, +) error { + if !c.CanHandleTokenEndpointRequest(ctx, request) { + return errorsx.WithStack(fosite.ErrUnknownRequest) + } + + expiry := time.Now().UTC().Add(c.Config.GetVerifiableCredentialsNonceLifespan(ctx)) + nonce, err := c.NewNonce(ctx, response.GetAccessToken(), expiry) + if err != nil { + return err + } + + response.SetExtra(draftNonceField, nonce) + response.SetExtra(draftNonceExpField, expiry.Unix()) + + return nil +} + +func (c *Handler) CanSkipClientAuth(context.Context, fosite.AccessRequester) bool { + return false +} + +func (c *Handler) CanHandleTokenEndpointRequest(_ context.Context, requester fosite.AccessRequester) bool { + return requester.GetGrantedScopes().Has(draftScope) +} diff --git a/handler/verifiable/handler_test.go b/handler/verifiable/handler_test.go new file mode 100644 index 000000000..145ee5a49 --- /dev/null +++ b/handler/verifiable/handler_test.go @@ -0,0 +1,70 @@ +package verifiable + +import ( + "context" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + + "github.com/ory/fosite" + "github.com/ory/fosite/internal" +) + +type mockNonceManager struct{ t *testing.T } + +func (m *mockNonceManager) NewNonce(_ context.Context, accessToken string, exp time.Time) (string, error) { + assert.Equal(m.t, "fake access token", accessToken) + assert.WithinDuration(m.t, time.Now().Add(time.Hour), exp, 5*time.Second) + return "mocked nonce", nil +} + +func (m *mockNonceManager) IsNonceValid(context.Context, string, string) error { + return nil +} + +func TestHandler(t *testing.T) { + t.Parallel() + ctx := context.Background() + + t.Run("case=correct scopes", func(t *testing.T) { + t.Parallel() + handler := newHandler(t) + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + req := internal.NewMockAccessRequester(ctrl) + req.EXPECT().GetGrantedScopes().Return(fosite.Arguments{"openid", draftScope}).AnyTimes() + + resp := internal.NewMockAccessResponder(ctrl) + resp.EXPECT().GetAccessToken().Return("fake access token") + resp.EXPECT().SetExtra(gomock.Eq(draftNonceField), gomock.Eq("mocked nonce")) + resp.EXPECT().SetExtra(gomock.Eq(draftNonceExpField), gomock.Any()) + + assert.NoError(t, handler.HandleTokenEndpointRequest(ctx, req)) + assert.NoError(t, handler.PopulateTokenEndpointResponse(ctx, req, resp)) + }) + + t.Run("case=incorrect scopes", func(t *testing.T) { + t.Parallel() + handler := newHandler(t) + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + req := internal.NewMockAccessRequester(ctrl) + req.EXPECT().GetGrantedScopes().Return(fosite.Arguments{"openid"}).AnyTimes() + + resp := internal.NewMockAccessResponder(ctrl) + + assert.ErrorIs(t, handler.HandleTokenEndpointRequest(ctx, req), fosite.ErrUnknownRequest) + assert.ErrorIs(t, handler.PopulateTokenEndpointResponse(ctx, req, resp), fosite.ErrUnknownRequest) + }) +} + +func newHandler(t *testing.T) *Handler { + return &Handler{ + Config: new(fosite.Config), + NonceManager: &mockNonceManager{t: t}, + } +} diff --git a/handler/verifiable/nonce.go b/handler/verifiable/nonce.go new file mode 100644 index 000000000..13f1504b9 --- /dev/null +++ b/handler/verifiable/nonce.go @@ -0,0 +1,14 @@ +package verifiable + +import ( + "context" + "time" +) + +type NonceManager interface { + // NewNonce creates a new nonce bound to the access token valid until the given expiry time. + NewNonce(ctx context.Context, accessToken string, expiresIn time.Time) (string, error) + + // IsNonceValid checks if the given nonce is valid for the given access token and not expired. + IsNonceValid(ctx context.Context, accessToken string, nonce string) error +}