From 0a6c96620674e880cfbff2e077f7faf12ca0e446 Mon Sep 17 00:00:00 2001 From: hackerman <3372410+aeneasr@users.noreply.github.com> Date: Tue, 17 Dec 2024 09:57:05 +0100 Subject: [PATCH] feat: handle concurrent refreshes and improve graceful refreshing (#3895) This patch improves Ory Hydra's ability to deal with refresh flows which, for example, concurrently refresh the same token. Furthermore, graceful token refresh has been improved to handle a variety of edge cases and scenarios. Additionally, serializability errors in CockroachDB are now correctly retried. See https://github.com/ory-corp/cloud/issues/7311 Closes https://github.com/ory/hydra/pull/3895 --- .docker/Dockerfile-hsm | 22 +- .schema/config.schema.json | 12 +- Makefile | 5 +- aead/aead_test.go | 17 +- client/handler_test.go | 3 +- client/sdk_test.go | 8 +- client/validator_test.go | 17 +- cmd/cmd_helper_test.go | 3 +- consent/handler_test.go | 19 +- consent/sdk_test.go | 7 +- consent/strategy_logout_test.go | 3 +- consent/strategy_oauth_test.go | 3 +- consent/test/manager_test_helpers.go | 2 + cypress/integration/oauth2/refresh_token.js | 8 +- driver/config/provider.go | 12 +- driver/config/provider_test.go | 2 +- go.mod | 10 +- go.sum | 20 +- health/handler_test.go | 7 +- internal/{ => testhelpers}/driver.go | 18 +- internal/testhelpers/janitor_test_helper.go | 5 +- internal/testhelpers/oauth2.go | 19 +- jwk/handler_test.go | 7 +- jwk/helper_test.go | 5 +- jwk/jwt_strategy_test.go | 7 +- jwk/sdk_test.go | 7 +- oauth2/equalKeys.go | 55 - oauth2/equalKeys_test.go | 20 - ...elpers.go => fosite_store_helpers_test.go} | 654 +++-- oauth2/fosite_store_test.go | 56 +- oauth2/handler.go | 7 +- oauth2/handler_fallback_endpoints_test.go | 7 +- oauth2/handler_test.go | 20 +- oauth2/helper_test.go | 62 + oauth2/helpers.go | 51 + oauth2/introspector_test.go | 8 +- oauth2/oauth2_auth_code_bench_test.go | 3 +- oauth2/oauth2_auth_code_test.go | 2427 +++++++++-------- .../oauth2_client_credentials_bench_test.go | 3 +- oauth2/oauth2_client_credentials_test.go | 3 +- oauth2/oauth2_jwt_bearer_test.go | 3 +- oauth2/oauth2_refresh_token_test.go | 272 -- oauth2/oauth2_rop_test.go | 3 +- oauth2/revocator_test.go | 8 +- oauth2/session_custom_claims_test.go | 5 +- oauth2/trust/handler_test.go | 9 +- persistence/sql/migratest/migration_test.go | 5 +- ...oken_access_token_link.autocommit.down.sql | 1 + ..._token_access_token_link.autocommit.up.sql | 1 + persistence/sql/persister.go | 1 - persistence/sql/persister_nid_test.go | 134 +- persistence/sql/persister_nonce_test.go | 5 +- persistence/sql/persister_oauth2.go | 218 +- persistence/sql/persister_test.go | 13 +- spec/config.json | 4 +- x/oauth2cors/cors_test.go | 7 +- x/tls_termination_test.go | 9 +- 57 files changed, 2323 insertions(+), 1999 deletions(-) rename internal/{ => testhelpers}/driver.go (84%) delete mode 100644 oauth2/equalKeys.go delete mode 100644 oauth2/equalKeys_test.go rename oauth2/{fosite_store_helpers.go => fosite_store_helpers_test.go} (65%) create mode 100644 oauth2/helpers.go delete mode 100644 oauth2/oauth2_refresh_token_test.go create mode 100644 persistence/sql/migrations/20241129111700000000_add_refresh_token_access_token_link.autocommit.down.sql create mode 100644 persistence/sql/migrations/20241129111700000000_add_refresh_token_access_token_link.autocommit.up.sql diff --git a/.docker/Dockerfile-hsm b/.docker/Dockerfile-hsm index 39cd1b1ad99..c4199fe87e4 100644 --- a/.docker/Dockerfile-hsm +++ b/.docker/Dockerfile-hsm @@ -17,20 +17,32 @@ COPY . . ############################### -FROM builder as build-hydra +FROM builder AS build-hydra RUN go build -tags sqlite,hsm -o /usr/bin/hydra ############################### -FROM builder as test-hsm +FROM builder AS test-hsm ENV HSM_ENABLED=true ENV HSM_LIBRARY=/usr/lib/softhsm/libsofthsm2.so ENV HSM_TOKEN_LABEL=hydra ENV HSM_PIN=1234 -RUN apt-get -y install softhsm opensc &&\ - pkcs11-tool --module "$HSM_LIBRARY" --slot 0 --init-token --so-pin 0000 --init-pin --pin "$HSM_PIN" --label "$HSM_TOKEN_LABEL" &&\ - go test -p 1 -v -failfast -short -tags=sqlite,hsm ./... +RUN apt-get -y install softhsm opensc +RUN pkcs11-tool --module "$HSM_LIBRARY" --slot 0 --init-token --so-pin 0000 --init-pin --pin "$HSM_PIN" --label "$HSM_TOKEN_LABEL" +RUN go test -p 1 -failfast -short -tags=sqlite,hsm ./... + + +FROM builder AS test-refresh-hsm +ENV HSM_ENABLED=true +ENV HSM_LIBRARY=/usr/lib/softhsm/libsofthsm2.so +ENV HSM_TOKEN_LABEL=hydra +ENV HSM_PIN=1234 +ENV UPDATE_SNAPSHOTS=true + +RUN apt-get -y install softhsm opensc +RUN pkcs11-tool --module "$HSM_LIBRARY" --slot 0 --init-token --so-pin 0000 --init-pin --pin "$HSM_PIN" --label "$HSM_TOKEN_LABEL" +RUN go test -p 1 -failfast -short -tags=sqlite,hsm,refresh ./... ############################### diff --git a/.schema/config.schema.json b/.schema/config.schema.json index bc1d1476c08..804e6b6024f 100644 --- a/.schema/config.schema.json +++ b/.schema/config.schema.json @@ -1101,11 +1101,11 @@ "examples": ["https://my-example.app/token-refresh-hook"], "oneOf": [ { - "type": "string", - "format": "uri" + "$ref": "#/definitions/webhook_config" }, { - "$ref": "#/definitions/webhook_config" + "type": "string", + "format": "uri" } ] }, @@ -1114,11 +1114,11 @@ "examples": ["https://my-example.app/token-hook"], "oneOf": [ { - "type": "string", - "format": "uri" + "$ref": "#/definitions/webhook_config" }, { - "$ref": "#/definitions/webhook_config" + "type": "string", + "format": "uri" } ] } diff --git a/Makefile b/Makefile index 75b912e0521..49c66ec5a71 100644 --- a/Makefile +++ b/Makefile @@ -90,9 +90,10 @@ quicktest: quicktest-hsm: DOCKER_BUILDKIT=1 DOCKER_CONTENT_TRUST=1 docker build --progress=plain -f .docker/Dockerfile-hsm --target test-hsm -t oryd/hydra:${IMAGE_TAG} --target test-hsm . -.PHONY: refresh -refresh: +.PHONY: test-refresh +test-refresh: UPDATE_SNAPSHOTS=true go test -failfast -short -tags sqlite,sqlite_omit_load_extension ./... + DOCKER_BUILDKIT=1 DOCKER_CONTENT_TRUST=1 docker build --progress=plain -f .docker/Dockerfile-hsm --target test-refresh-hsm -t oryd/hydra:${IMAGE_TAG} --target test-refresh-hsm . authors: # updates the AUTHORS file curl https://raw.githubusercontent.com/ory/ci/master/authors/authors.sh | env PRODUCT="Ory Hydra" bash diff --git a/aead/aead_test.go b/aead/aead_test.go index 4cb93f5c3e7..d1b614710a2 100644 --- a/aead/aead_test.go +++ b/aead/aead_test.go @@ -10,13 +10,14 @@ import ( "io" "testing" - "github.com/ory/hydra/v2/aead" - "github.com/ory/hydra/v2/driver/config" - "github.com/ory/hydra/v2/internal" + "github.com/ory/hydra/v2/internal/testhelpers" "github.com/pborman/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/ory/hydra/v2/aead" + "github.com/ory/hydra/v2/driver/config" ) func secret(t *testing.T) string { @@ -43,7 +44,7 @@ func TestAEAD(t *testing.T) { t.Run("case=without-rotation", func(t *testing.T) { t.Parallel() ctx := context.Background() - c := internal.NewConfigurationWithDefaults() + c := testhelpers.NewConfigurationWithDefaults() c.MustSet(ctx, config.KeyGetSystemSecret, []string{secret(t)}) a := NewCipher(c) @@ -63,7 +64,7 @@ func TestAEAD(t *testing.T) { t.Run("case=wrong-secret", func(t *testing.T) { t.Parallel() ctx := context.Background() - c := internal.NewConfigurationWithDefaults() + c := testhelpers.NewConfigurationWithDefaults() c.MustSet(ctx, config.KeyGetSystemSecret, []string{secret(t)}) a := NewCipher(c) @@ -78,7 +79,7 @@ func TestAEAD(t *testing.T) { t.Run("case=with-rotation", func(t *testing.T) { t.Parallel() ctx := context.Background() - c := internal.NewConfigurationWithDefaults() + c := testhelpers.NewConfigurationWithDefaults() old := secret(t) c.MustSet(ctx, config.KeyGetSystemSecret, []string{old}) a := NewCipher(c) @@ -106,7 +107,7 @@ func TestAEAD(t *testing.T) { t.Run("case=with-rotation-wrong-secret", func(t *testing.T) { t.Parallel() ctx := context.Background() - c := internal.NewConfigurationWithDefaults() + c := testhelpers.NewConfigurationWithDefaults() c.MustSet(ctx, config.KeyGetSystemSecret, []string{secret(t)}) a := NewCipher(c) @@ -123,7 +124,7 @@ func TestAEAD(t *testing.T) { t.Run("suite=with additional data", func(t *testing.T) { t.Parallel() ctx := context.Background() - c := internal.NewConfigurationWithDefaults() + c := testhelpers.NewConfigurationWithDefaults() c.MustSet(ctx, config.KeyGetSystemSecret, []string{secret(t)}) a := NewCipher(c) diff --git a/client/handler_test.go b/client/handler_test.go index 3047ad4c87b..8e27caea754 100644 --- a/client/handler_test.go +++ b/client/handler_test.go @@ -35,7 +35,6 @@ import ( "github.com/stretchr/testify/require" "github.com/ory/hydra/v2/client" - "github.com/ory/hydra/v2/internal" ) type responseSnapshot struct { @@ -56,7 +55,7 @@ func getClientID(body string) string { func TestHandler(t *testing.T) { ctx := context.Background() - reg := internal.NewMockedRegistry(t, &contextx.Default{}) + reg := testhelpers.NewMockedRegistry(t, &contextx.Default{}) h := client.NewHandler(reg) reg.WithContextualizer(&contextx.TestContextualizer{}) diff --git a/client/sdk_test.go b/client/sdk_test.go index 9db7ab7cddb..ad3193108ad 100644 --- a/client/sdk_test.go +++ b/client/sdk_test.go @@ -9,6 +9,8 @@ import ( "strings" "testing" + "github.com/ory/hydra/v2/internal/testhelpers" + "github.com/ory/x/assertx" "github.com/ory/x/ioutilx" @@ -26,8 +28,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/ory/hydra/v2/internal" - hydra "github.com/ory/hydra-client-go/v2" "github.com/ory/hydra/v2/client" ) @@ -63,11 +63,11 @@ var defaultIgnoreFields = []string{"client_id", "registration_access_token", "re func TestClientSDK(t *testing.T) { ctx := context.Background() - conf := internal.NewConfigurationWithDefaults() + conf := testhelpers.NewConfigurationWithDefaults() conf.MustSet(ctx, config.KeySubjectTypesSupported, []string{"public"}) conf.MustSet(ctx, config.KeyDefaultClientScope, []string{"foo", "bar"}) conf.MustSet(ctx, config.KeyPublicAllowDynamicRegistration, true) - r := internal.NewRegistryMemory(t, conf, &contextx.Static{C: conf.Source(ctx)}) + r := testhelpers.NewRegistryMemory(t, conf, &contextx.Static{C: conf.Source(ctx)}) routerAdmin := x.NewRouterAdmin(conf.AdminURL) routerPublic := x.NewRouterPublic() diff --git a/client/validator_test.go b/client/validator_test.go index 09f69b26e30..4efe866d5a9 100644 --- a/client/validator_test.go +++ b/client/validator_test.go @@ -12,6 +12,8 @@ import ( "strings" "testing" + "github.com/ory/hydra/v2/internal/testhelpers" + "github.com/hashicorp/go-retryablehttp" "github.com/ory/fosite" @@ -24,17 +26,16 @@ import ( . "github.com/ory/hydra/v2/client" "github.com/ory/hydra/v2/driver/config" - "github.com/ory/hydra/v2/internal" "github.com/ory/hydra/v2/x" "github.com/ory/x/contextx" ) func TestValidate(t *testing.T) { ctx := context.Background() - c := internal.NewConfigurationWithDefaults() + c := testhelpers.NewConfigurationWithDefaults() c.MustSet(ctx, config.KeySubjectTypesSupported, []string{"pairwise", "public"}) c.MustSet(ctx, config.KeyDefaultClientScope, []string{"openid"}) - reg := internal.NewRegistryMemory(t, c, &contextx.Static{C: c.Source(ctx)}) + reg := testhelpers.NewRegistryMemory(t, c, &contextx.Static{C: c.Source(ctx)}) v := NewValidator(reg) testCtx := context.TODO() @@ -186,7 +187,7 @@ func (f *fakeHTTP) HTTPClient(ctx context.Context, opts ...httpx.ResilientOption } func TestValidateSectorIdentifierURL(t *testing.T) { - reg := internal.NewMockedRegistry(t, &contextx.Default{}) + reg := testhelpers.NewMockedRegistry(t, &contextx.Default{}) var payload string var h http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) { @@ -268,8 +269,8 @@ const validJWKS = ` func TestValidateIPRanges(t *testing.T) { ctx := context.Background() - c := internal.NewConfigurationWithDefaults() - reg := internal.NewRegistryMemory(t, c, &contextx.Static{C: c.Source(ctx)}) + c := testhelpers.NewConfigurationWithDefaults() + reg := testhelpers.NewRegistryMemory(t, c, &contextx.Static{C: c.Source(ctx)}) v := NewValidator(reg) c.MustSet(ctx, config.KeyClientHTTPNoPrivateIPRanges, true) @@ -287,10 +288,10 @@ func TestValidateIPRanges(t *testing.T) { func TestValidateDynamicRegistration(t *testing.T) { ctx := context.Background() - c := internal.NewConfigurationWithDefaults() + c := testhelpers.NewConfigurationWithDefaults() c.MustSet(ctx, config.KeySubjectTypesSupported, []string{"pairwise", "public"}) c.MustSet(ctx, config.KeyDefaultClientScope, []string{"openid"}) - reg := internal.NewRegistryMemory(t, c, &contextx.Static{C: c.Source(ctx)}) + reg := testhelpers.NewRegistryMemory(t, c, &contextx.Static{C: c.Source(ctx)}) testCtx := context.TODO() v := NewValidator(reg) diff --git a/cmd/cmd_helper_test.go b/cmd/cmd_helper_test.go index da386b4865d..4953f6b4321 100644 --- a/cmd/cmd_helper_test.go +++ b/cmd/cmd_helper_test.go @@ -19,7 +19,6 @@ import ( "github.com/ory/hydra/v2/client" "github.com/ory/hydra/v2/driver" - "github.com/ory/hydra/v2/internal" "github.com/ory/hydra/v2/internal/testhelpers" "github.com/ory/x/cmdx" "github.com/ory/x/contextx" @@ -40,7 +39,7 @@ func setupRoutes(t *testing.T, cmd *cobra.Command) (*httptest.Server, *httptest. ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - reg := internal.NewMockedRegistry(t, &contextx.Default{}) + reg := testhelpers.NewMockedRegistry(t, &contextx.Default{}) public, admin := testhelpers.NewOAuth2Server(ctx, t, reg) cmdx.RegisterHTTPClientFlags(cmd.Flags()) diff --git a/consent/handler_test.go b/consent/handler_test.go index d5dfe5254ad..45ba2b7733a 100644 --- a/consent/handler_test.go +++ b/consent/handler_test.go @@ -13,13 +13,14 @@ import ( "testing" "time" + "github.com/ory/hydra/v2/internal/testhelpers" + "github.com/stretchr/testify/require" hydra "github.com/ory/hydra-client-go/v2" "github.com/ory/hydra/v2/client" . "github.com/ory/hydra/v2/consent" "github.com/ory/hydra/v2/flow" - "github.com/ory/hydra/v2/internal" "github.com/ory/hydra/v2/x" "github.com/ory/x/contextx" "github.com/ory/x/pointerx" @@ -42,8 +43,8 @@ func TestGetLogoutRequest(t *testing.T) { challenge := "challenge" + key requestURL := "http://192.0.2.1" - conf := internal.NewConfigurationWithDefaults() - reg := internal.NewRegistryMemory(t, conf, &contextx.Default{}) + conf := testhelpers.NewConfigurationWithDefaults() + reg := testhelpers.NewRegistryMemory(t, conf, &contextx.Default{}) if tc.exists { cl := &client.Client{ID: "client" + key} @@ -97,8 +98,8 @@ func TestGetLoginRequest(t *testing.T) { challenge := "challenge" + key requestURL := "http://192.0.2.1" - conf := internal.NewConfigurationWithDefaults() - reg := internal.NewRegistryMemory(t, conf, &contextx.Default{}) + conf := testhelpers.NewConfigurationWithDefaults() + reg := testhelpers.NewRegistryMemory(t, conf, &contextx.Default{}) if tc.exists { cl := &client.Client{ID: "client" + key} @@ -163,8 +164,8 @@ func TestGetConsentRequest(t *testing.T) { challenge := "challenge" + key requestURL := "http://192.0.2.1" - conf := internal.NewConfigurationWithDefaults() - reg := internal.NewRegistryMemory(t, conf, &contextx.Default{}) + conf := testhelpers.NewConfigurationWithDefaults() + reg := testhelpers.NewRegistryMemory(t, conf, &contextx.Default{}) if tc.exists { cl := &client.Client{ID: "client" + key} @@ -238,8 +239,8 @@ func TestGetLoginRequestWithDuplicateAccept(t *testing.T) { challenge := "challenge" requestURL := "http://192.0.2.1" - conf := internal.NewConfigurationWithDefaults() - reg := internal.NewRegistryMemory(t, conf, &contextx.Default{}) + conf := testhelpers.NewConfigurationWithDefaults() + reg := testhelpers.NewRegistryMemory(t, conf, &contextx.Default{}) cl := &client.Client{ID: "client"} require.NoError(t, reg.ClientManager().CreateClient(ctx, cl)) diff --git a/consent/sdk_test.go b/consent/sdk_test.go index f749428d5d8..0f30d16e7c8 100644 --- a/consent/sdk_test.go +++ b/consent/sdk_test.go @@ -11,6 +11,8 @@ import ( "testing" "time" + "github.com/ory/hydra/v2/internal/testhelpers" + "github.com/ory/hydra/v2/consent/test" hydra "github.com/ory/hydra-client-go/v2" @@ -23,7 +25,6 @@ import ( . "github.com/ory/hydra/v2/consent" "github.com/ory/hydra/v2/driver/config" - "github.com/ory/hydra/v2/internal" "github.com/ory/hydra/v2/x" "github.com/ory/x/contextx" ) @@ -35,10 +36,10 @@ func makeID(base string, network string, key string) string { func TestSDK(t *testing.T) { ctx := context.Background() network := "t1" - conf := internal.NewConfigurationWithDefaults() + conf := testhelpers.NewConfigurationWithDefaults() conf.MustSet(ctx, config.KeyIssuerURL, "https://www.ory.sh") conf.MustSet(ctx, config.KeyAccessTokenLifespan, time.Minute) - reg := internal.NewRegistryMemory(t, conf, &contextx.Default{}) + reg := testhelpers.NewRegistryMemory(t, conf, &contextx.Default{}) consentChallenge := func(f *Flow) string { return x.Must(f.ToConsentChallenge(ctx, reg)) } consentVerifier := func(f *Flow) string { return x.Must(f.ToConsentVerifier(ctx, reg)) } diff --git a/consent/strategy_logout_test.go b/consent/strategy_logout_test.go index 6432a3e13a0..80e633e7bf6 100644 --- a/consent/strategy_logout_test.go +++ b/consent/strategy_logout_test.go @@ -28,7 +28,6 @@ import ( hydra "github.com/ory/hydra-client-go/v2" "github.com/ory/hydra/v2/client" "github.com/ory/hydra/v2/driver/config" - "github.com/ory/hydra/v2/internal" "github.com/ory/hydra/v2/internal/testhelpers" "github.com/ory/x/contextx" "github.com/ory/x/ioutilx" @@ -37,7 +36,7 @@ import ( func TestLogoutFlows(t *testing.T) { ctx := context.Background() fakeKratos := kratos.NewFake() - reg := internal.NewMockedRegistry(t, &contextx.Default{}) + reg := testhelpers.NewMockedRegistry(t, &contextx.Default{}) reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "opaque") reg.Config().MustSet(ctx, config.KeyConsentRequestMaxAge, time.Hour) diff --git a/consent/strategy_oauth_test.go b/consent/strategy_oauth_test.go index 370a3378074..a2e39d5b6ec 100644 --- a/consent/strategy_oauth_test.go +++ b/consent/strategy_oauth_test.go @@ -37,12 +37,11 @@ import ( hydra "github.com/ory/hydra-client-go/v2" "github.com/ory/hydra/v2/client" "github.com/ory/hydra/v2/driver/config" - "github.com/ory/hydra/v2/internal" ) func TestStrategyLoginConsentNext(t *testing.T) { ctx := context.Background() - reg := internal.NewMockedRegistry(t, &contextx.Default{}) + reg := testhelpers.NewMockedRegistry(t, &contextx.Default{}) reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "opaque") reg.Config().MustSet(ctx, config.KeyConsentRequestMaxAge, time.Hour) reg.Config().MustSet(ctx, config.KeyConsentRequestMaxAge, time.Hour) diff --git a/consent/test/manager_test_helpers.go b/consent/test/manager_test_helpers.go index a5b141f5359..986b4f3144c 100644 --- a/consent/test/manager_test_helpers.go +++ b/consent/test/manager_test_helpers.go @@ -683,6 +683,7 @@ func ManagerTests(deps Deps, m consent.Manager, clientManager client.Manager, fo require.NoError(t, fositeManager.CreateRefreshTokenSession( ctx, makeID("", network, "rrva1"), + "", &fosite.Request{Client: cr1.Client, ID: crr1.ID, RequestedAt: time.Now(), Session: &oauth2.Session{DefaultSession: openid.NewDefaultSession()}}, )) require.NoError(t, fositeManager.CreateAccessTokenSession( @@ -693,6 +694,7 @@ func ManagerTests(deps Deps, m consent.Manager, clientManager client.Manager, fo require.NoError(t, fositeManager.CreateRefreshTokenSession( ctx, makeID("", network, "rrva2"), + "", &fosite.Request{Client: cr2.Client, ID: crr2.ID, RequestedAt: time.Now(), Session: &oauth2.Session{DefaultSession: openid.NewDefaultSession()}}, )) diff --git a/cypress/integration/oauth2/refresh_token.js b/cypress/integration/oauth2/refresh_token.js index fbbbf36e80b..2ddf7d30f19 100644 --- a/cypress/integration/oauth2/refresh_token.js +++ b/cypress/integration/oauth2/refresh_token.js @@ -87,13 +87,13 @@ describe("The OAuth 2.0 Refresh Token Grant", function () { return cy .refreshTokenBrowser(client, originalToken) .then((response) => { - expect(response.status).to.eq(401) - expect(response.body.error).to.eq("token_inactive") + expect(response.status).to.eq(400) + expect(response.body.error).to.eq("invalid_grant") }) .then(() => cy.refreshTokenBrowser(client, refreshedToken)) .then((response) => { - expect(response.status).to.eq(401) - expect(response.body.error).to.eq("token_inactive") + expect(response.status).to.eq(400) + expect(response.body.error).to.eq("invalid_grant") }) }, ) diff --git a/driver/config/provider.go b/driver/config/provider.go index 52b9ee45a3f..b02d0ae1da4 100644 --- a/driver/config/provider.go +++ b/driver/config/provider.go @@ -213,6 +213,10 @@ func (p *DefaultProvider) MustSet(ctx context.Context, key string, value interfa } } +func (p *DefaultProvider) Delete(ctx context.Context, key string) { + p.getProvider(ctx).Delete(key) +} + func (p *DefaultProvider) Source(ctx context.Context) *configx.Provider { return p.getProvider(ctx) } @@ -517,6 +521,10 @@ type ( ) func (p *DefaultProvider) getHookConfig(ctx context.Context, key string) *HookConfig { + if p.getProvider(ctx).String(key) == "" { + return nil + } + if hookURL := p.getProvider(ctx).RequestURIF(key, nil); hookURL != nil { return &HookConfig{ URL: hookURL.String(), @@ -673,8 +681,8 @@ func (p *DefaultProvider) cookieSuffix(ctx context.Context, key string) string { func (p *DefaultProvider) RefreshTokenRotationGracePeriod(ctx context.Context) time.Duration { gracePeriod := p.getProvider(ctx).DurationF(KeyRefreshTokenRotationGracePeriod, 0) - if gracePeriod > time.Hour { - return time.Hour + if gracePeriod > time.Minute*5 { + return time.Minute * 5 } return gracePeriod } diff --git a/driver/config/provider_test.go b/driver/config/provider_test.go index 168ca81d69f..7ec1dce8df9 100644 --- a/driver/config/provider_test.go +++ b/driver/config/provider_test.go @@ -296,7 +296,7 @@ func TestViperProviderValidates(t *testing.T) { require.NoError(t, c.Set(ctx, KeyRefreshTokenRotationGracePeriod, "1s")) assert.Equal(t, time.Second, c.RefreshTokenRotationGracePeriod(ctx)) require.NoError(t, c.Set(ctx, KeyRefreshTokenRotationGracePeriod, "2h")) - assert.Equal(t, time.Hour, c.RefreshTokenRotationGracePeriod(ctx)) + assert.Equal(t, time.Minute*5, c.RefreshTokenRotationGracePeriod(ctx)) // urls assert.Equal(t, urlx.ParseOrPanic("https://issuer"), c.IssuerURL(ctx)) diff --git a/go.mod b/go.mod index 0c9b9277cdf..fbf5626ba9c 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,7 @@ require ( github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 github.com/oleiade/reflections v1.0.1 github.com/ory/analytics-go/v5 v5.0.1 - github.com/ory/fosite v0.48.0 + github.com/ory/fosite v0.49.0 github.com/ory/go-acc v0.2.9-0.20230103102148-6b1c9a70dbbe github.com/ory/graceful v0.1.3 github.com/ory/herodot v0.10.3-0.20230626083119-d7e5192f0d88 @@ -62,10 +62,10 @@ require ( go.opentelemetry.io/otel/sdk v1.32.0 go.opentelemetry.io/otel/trace v1.32.0 go.uber.org/automaxprocs v1.5.3 - golang.org/x/crypto v0.28.0 + golang.org/x/crypto v0.31.0 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 golang.org/x/oauth2 v0.23.0 - golang.org/x/sync v0.9.0 + golang.org/x/sync v0.10.0 golang.org/x/tools v0.23.0 ) @@ -235,8 +235,8 @@ require ( go.uber.org/multierr v1.11.0 // indirect golang.org/x/mod v0.19.0 // indirect golang.org/x/net v0.30.0 // indirect - golang.org/x/sys v0.27.0 // indirect - golang.org/x/text v0.20.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 // indirect diff --git a/go.sum b/go.sum index c29c141d383..a98230b2896 100644 --- a/go.sum +++ b/go.sum @@ -378,8 +378,8 @@ github.com/ory/analytics-go/v5 v5.0.1 h1:LX8T5B9FN8KZXOtxgN+R3I4THRRVB6+28IKgKBp github.com/ory/analytics-go/v5 v5.0.1/go.mod h1:lWCiCjAaJkKfgR/BN5DCLMol8BjKS1x+4jxBxff/FF0= github.com/ory/dockertest/v3 v3.10.1-0.20240704115616-d229e74b748d h1:By96ZSVuH5LyjXLVVMfvJoLVGHaT96LdOnwgFSLVf0E= github.com/ory/dockertest/v3 v3.10.1-0.20240704115616-d229e74b748d/go.mod h1:F2FIjwwAk6CsNAs//B8+aPFQF0t84pbM8oliyNXwQrk= -github.com/ory/fosite v0.48.0 h1:zxNPNrCBsFwujviVPhbHZzSHZNzjBFZ36MeBFz6tCuU= -github.com/ory/fosite v0.48.0/go.mod h1:M+C+Ng1UDNgwX4SaErnuZwEw26uDN7I3kNUt0WyValI= +github.com/ory/fosite v0.49.0 h1:KNqO7RVt/1X8F08/UI0Y+GRvcpscCWgjqvpLBQPRovo= +github.com/ory/fosite v0.49.0/go.mod h1:FAn7IY+I6DjT1r29wMouPeRYq63DWUuBj++96uOS4mE= github.com/ory/go-acc v0.2.9-0.20230103102148-6b1c9a70dbbe h1:rvu4obdvqR0fkSIJ8IfgzKOWwZ5kOT2UNfLq81Qk7rc= github.com/ory/go-acc v0.2.9-0.20230103102148-6b1c9a70dbbe/go.mod h1:z4n3u6as84LbV4YmgjHhnwtccQqzf4cZlSk9f1FhygI= github.com/ory/go-convenience v0.1.0 h1:zouLKfF2GoSGnJwGq+PE/nJAE6dj2Zj5QlTgmMTsTS8= @@ -570,8 +570,8 @@ golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4 golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= -golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -609,8 +609,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= -golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -633,8 +633,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= @@ -651,8 +651,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= -golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/health/handler_test.go b/health/handler_test.go index 4b717a02c79..b7821d3cab4 100644 --- a/health/handler_test.go +++ b/health/handler_test.go @@ -9,6 +9,8 @@ import ( "net/http/httptest" "testing" + "github.com/ory/hydra/v2/internal/testhelpers" + "github.com/stretchr/testify/assert" "github.com/ory/x/contextx" @@ -16,7 +18,6 @@ import ( "github.com/stretchr/testify/require" "github.com/ory/hydra/v2/driver/config" - "github.com/ory/hydra/v2/internal" "github.com/ory/hydra/v2/x" "github.com/ory/x/healthx" ) @@ -71,12 +72,12 @@ func TestPublicHealthHandler(t *testing.T) { }, } { t.Run(tc.name, func(t *testing.T) { - conf := internal.NewConfigurationWithDefaults() + conf := testhelpers.NewConfigurationWithDefaults() for k, v := range tc.config { conf.MustSet(ctx, config.PublicInterface.Key(k), v) } - reg := internal.NewRegistryMemory(t, conf, &contextx.Default{}) + reg := testhelpers.NewRegistryMemory(t, conf, &contextx.Default{}) public := x.NewRouterPublic() reg.RegisterRoutes(ctx, x.NewRouterAdmin(conf.AdminURL), public) diff --git a/internal/driver.go b/internal/testhelpers/driver.go similarity index 84% rename from internal/driver.go rename to internal/testhelpers/driver.go index 38a8d8144d4..34a3f40b8bd 100644 --- a/internal/driver.go +++ b/internal/testhelpers/driver.go @@ -1,13 +1,15 @@ // Copyright © 2022 Ory Corp // SPDX-License-Identifier: Apache-2.0 -package internal +package testhelpers import ( "context" "sync" "testing" + "github.com/ory/x/dbal" + "github.com/go-jose/go-jose/v3" "github.com/stretchr/testify/require" @@ -44,24 +46,28 @@ func NewConfigurationWithDefaultsAndHTTPS() *config.DefaultProvider { } func NewRegistryMemory(t testing.TB, c *config.DefaultProvider, ctxer contextx.Contextualizer) driver.Registry { - return newRegistryDefault(t, "memory", c, true, ctxer) + return registryFactory(t, dbal.NewSQLiteTestDatabase(t), c, true, ctxer) } func NewMockedRegistry(t testing.TB, ctxer contextx.Contextualizer) driver.Registry { - return newRegistryDefault(t, "memory", NewConfigurationWithDefaults(), true, ctxer) + return registryFactory(t, dbal.NewSQLiteTestDatabase(t), NewConfigurationWithDefaults(), true, ctxer) } func NewRegistrySQLFromURL(t testing.TB, url string, migrate bool, ctxer contextx.Contextualizer) driver.Registry { - return newRegistryDefault(t, url, NewConfigurationWithDefaults(), migrate, ctxer) + return registryFactory(t, url, NewConfigurationWithDefaults(), migrate, ctxer) +} + +func registryFactory(t testing.TB, url string, c *config.DefaultProvider, migrate bool, ctxer contextx.Contextualizer) driver.Registry { + return RegistryFactory(t, url, c, !migrate, migrate, ctxer) } -func newRegistryDefault(t testing.TB, url string, c *config.DefaultProvider, migrate bool, ctxer contextx.Contextualizer) driver.Registry { +func RegistryFactory(t testing.TB, url string, c *config.DefaultProvider, networkInit, migrate bool, ctxer contextx.Contextualizer) driver.Registry { ctx := context.Background() c.MustSet(ctx, config.KeyLogLevel, "trace") c.MustSet(ctx, config.KeyDSN, url) c.MustSet(ctx, "dev", true) - r, err := driver.NewRegistryFromDSN(ctx, c, logrusx.New("test_hydra", "master"), false, migrate, ctxer) + r, err := driver.NewRegistryFromDSN(ctx, c, logrusx.New("test_hydra", "master"), networkInit, migrate, ctxer) require.NoError(t, err) return r diff --git a/internal/testhelpers/janitor_test_helper.go b/internal/testhelpers/janitor_test_helper.go index f70d7c27495..c452b3248f1 100644 --- a/internal/testhelpers/janitor_test_helper.go +++ b/internal/testhelpers/janitor_test_helper.go @@ -21,7 +21,6 @@ import ( "github.com/ory/hydra/v2/driver" "github.com/ory/hydra/v2/driver/config" "github.com/ory/hydra/v2/flow" - "github.com/ory/hydra/v2/internal" "github.com/ory/hydra/v2/oauth2" "github.com/ory/hydra/v2/oauth2/trust" "github.com/ory/hydra/v2/x" @@ -50,7 +49,7 @@ type createGrantRequest struct { const lifespan = time.Hour func NewConsentJanitorTestHelper(uniqueName string) *JanitorConsentTestHelper { - conf := internal.NewConfigurationWithDefaults() + conf := NewConfigurationWithDefaults() conf.MustSet(context.Background(), config.KeyScopeStrategy, "DEPRECATED_HIERARCHICAL_SCOPE_STRATEGY") conf.MustSet(context.Background(), config.KeyIssuerURL, "http://hydra.localhost") conf.MustSet(context.Background(), config.KeyAccessTokenLifespan, lifespan) @@ -126,7 +125,7 @@ func (j *JanitorConsentTestHelper) RefreshTokenNotAfterSetup(ctx context.Context // Create refresh token clients and session for _, fr := range j.flushRefreshRequests { require.NoError(t, cl.CreateClient(ctx, fr.Client.(*client.Client))) - require.NoError(t, store.CreateRefreshTokenSession(ctx, fr.ID, fr)) + require.NoError(t, store.CreateRefreshTokenSession(ctx, fr.ID, "", fr)) } } } diff --git a/internal/testhelpers/oauth2.go b/internal/testhelpers/oauth2.go index 41f0ddaec8e..4a7b5bc696e 100644 --- a/internal/testhelpers/oauth2.go +++ b/internal/testhelpers/oauth2.go @@ -32,7 +32,6 @@ import ( "github.com/ory/hydra/v2/client" "github.com/ory/hydra/v2/driver" "github.com/ory/hydra/v2/driver/config" - "github.com/ory/hydra/v2/internal" "github.com/ory/hydra/v2/x" ) @@ -67,8 +66,8 @@ func NewOAuth2Server(ctx context.Context, t testing.TB, reg driver.Registry) (pu public, admin := x.NewRouterPublic(), x.NewRouterAdmin(reg.Config().AdminURL) - internal.MustEnsureRegistryKeys(ctx, reg, x.OpenIDConnectKeyName) - internal.MustEnsureRegistryKeys(ctx, reg, x.OAuth2JWTKeyName) + MustEnsureRegistryKeys(ctx, reg, x.OpenIDConnectKeyName) + MustEnsureRegistryKeys(ctx, reg, x.OAuth2JWTKeyName) reg.RegisterRoutes(ctx, admin, public) @@ -111,6 +110,20 @@ func IntrospectToken(t testing.TB, conf *oauth2.Config, token string, adminTS *h return gjson.ParseBytes(ioutilx.MustReadAll(res.Body)) } +func RevokeToken(t testing.TB, conf *oauth2.Config, token string, publicTS *httptest.Server) gjson.Result { + require.NotEmpty(t, token) + + req := httpx.MustNewRequest("POST", publicTS.URL+"/oauth2/revoke", + strings.NewReader((url.Values{"token": {token}}).Encode()), + "application/x-www-form-urlencoded") + + req.SetBasicAuth(conf.ClientID, conf.ClientSecret) + res, err := publicTS.Client().Do(req) + require.NoError(t, err) + defer res.Body.Close() + return gjson.ParseBytes(ioutilx.MustReadAll(res.Body)) +} + func UpdateClientTokenLifespans(t *testing.T, conf *oauth2.Config, clientID string, lifespans client.Lifespans, adminTS *httptest.Server) { b, err := json.Marshal(lifespans) require.NoError(t, err) diff --git a/jwk/handler_test.go b/jwk/handler_test.go index 5df8182de60..0dc8f6afcdc 100644 --- a/jwk/handler_test.go +++ b/jwk/handler_test.go @@ -10,6 +10,8 @@ import ( "net/http/httptest" "testing" + "github.com/ory/hydra/v2/internal/testhelpers" + "github.com/ory/x/httprouterx" "github.com/ory/hydra/v2/jwk" @@ -20,15 +22,14 @@ import ( "github.com/stretchr/testify/require" "github.com/ory/hydra/v2/driver/config" - "github.com/ory/hydra/v2/internal" "github.com/ory/hydra/v2/x" ) func TestHandlerWellKnown(t *testing.T) { t.Parallel() - conf := internal.NewConfigurationWithDefaults() - reg := internal.NewRegistryMemory(t, conf, &contextx.Default{}) + conf := testhelpers.NewConfigurationWithDefaults() + reg := testhelpers.NewRegistryMemory(t, conf, &contextx.Default{}) conf.MustSet(context.Background(), config.KeyWellKnownKeys, []string{x.OpenIDConnectKeyName, x.OpenIDConnectKeyName}) router := x.NewRouterPublic() h := reg.KeyHandler() diff --git a/jwk/helper_test.go b/jwk/helper_test.go index c1a5ee46387..5a6dabd6a60 100644 --- a/jwk/helper_test.go +++ b/jwk/helper_test.go @@ -17,6 +17,8 @@ import ( "strings" "testing" + "github.com/ory/hydra/v2/internal/testhelpers" + hydra "github.com/ory/hydra-client-go/v2" "github.com/go-jose/go-jose/v3" @@ -27,7 +29,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/ory/hydra/v2/internal" "github.com/ory/hydra/v2/jwk" "github.com/ory/hydra/v2/x" "github.com/ory/x/contextx" @@ -210,7 +211,7 @@ func TestExcludeOpaquePrivateKeys(t *testing.T) { func TestGetOrGenerateKeys(t *testing.T) { t.Parallel() - reg := internal.NewMockedRegistry(t, &contextx.Default{}) + reg := testhelpers.NewMockedRegistry(t, &contextx.Default{}) setId := uuid.NewUUID().String() keyId := uuid.NewUUID().String() diff --git a/jwk/jwt_strategy_test.go b/jwk/jwt_strategy_test.go index 8389d20a610..b4def161005 100644 --- a/jwk/jwt_strategy_test.go +++ b/jwk/jwt_strategy_test.go @@ -9,12 +9,13 @@ import ( "strings" "testing" + "github.com/ory/hydra/v2/internal/testhelpers" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" "github.com/ory/fosite/token/jwt" - "github.com/ory/hydra/v2/internal" . "github.com/ory/hydra/v2/jwk" "github.com/ory/x/contextx" ) @@ -22,8 +23,8 @@ import ( func TestJWTStrategy(t *testing.T) { for _, alg := range []string{"RS256", "ES256", "ES512"} { t.Run("case="+alg, func(t *testing.T) { - conf := internal.NewConfigurationWithDefaults() - reg := internal.NewRegistryMemory(t, conf, &contextx.Default{}) + conf := testhelpers.NewConfigurationWithDefaults() + reg := testhelpers.NewRegistryMemory(t, conf, &contextx.Default{}) m := reg.KeyManager() _, err := m.GenerateAndPersistKeySet(context.Background(), "foo-set", "foo", alg, "sig") diff --git a/jwk/sdk_test.go b/jwk/sdk_test.go index f7f7d6a21e8..b2088239884 100644 --- a/jwk/sdk_test.go +++ b/jwk/sdk_test.go @@ -9,12 +9,13 @@ import ( "net/http/httptest" "testing" + "github.com/ory/hydra/v2/internal/testhelpers" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" hydra "github.com/ory/hydra-client-go/v2" "github.com/ory/hydra/v2/driver/config" - "github.com/ory/hydra/v2/internal" . "github.com/ory/hydra/v2/jwk" "github.com/ory/hydra/v2/x" "github.com/ory/x/contextx" @@ -23,8 +24,8 @@ import ( func TestJWKSDK(t *testing.T) { t.Parallel() ctx := context.Background() - conf := internal.NewConfigurationWithDefaults() - reg := internal.NewRegistryMemory(t, conf, &contextx.Default{}) + conf := testhelpers.NewConfigurationWithDefaults() + reg := testhelpers.NewRegistryMemory(t, conf, &contextx.Default{}) router := x.NewRouterAdmin(conf.AdminURL) h := NewHandler(reg) diff --git a/oauth2/equalKeys.go b/oauth2/equalKeys.go deleted file mode 100644 index e16568e078a..00000000000 --- a/oauth2/equalKeys.go +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright © 2022 Ory Corp -// SPDX-License-Identifier: Apache-2.0 - -package oauth2 - -import ( - "testing" - - "github.com/oleiade/reflections" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func AssertObjectKeysEqual(t *testing.T, a, b interface{}, keys ...string) { - assert.True(t, len(keys) > 0, "No keys provided.") - for _, k := range keys { - c, err := reflections.GetField(a, k) - assert.Nil(t, err) - d, err := reflections.GetField(b, k) - assert.Nil(t, err) - assert.Equal(t, c, d, "%s", k) - } -} - -func AssertObjectKeysNotEqual(t *testing.T, a, b interface{}, keys ...string) { - assert.True(t, len(keys) > 0, "No keys provided.") - for _, k := range keys { - c, err := reflections.GetField(a, k) - assert.Nil(t, err) - d, err := reflections.GetField(b, k) - assert.Nil(t, err) - assert.NotEqual(t, c, d, "%s", k) - } -} - -func RequireObjectKeysEqual(t *testing.T, a, b interface{}, keys ...string) { - assert.True(t, len(keys) > 0, "No keys provided.") - for _, k := range keys { - c, err := reflections.GetField(a, k) - assert.Nil(t, err) - d, err := reflections.GetField(b, k) - assert.Nil(t, err) - require.Equal(t, c, d, "%s", k) - } -} -func RequireObjectKeysNotEqual(t *testing.T, a, b interface{}, keys ...string) { - assert.True(t, len(keys) > 0, "No keys provided.") - for _, k := range keys { - c, err := reflections.GetField(a, k) - assert.Nil(t, err) - d, err := reflections.GetField(b, k) - assert.Nil(t, err) - require.NotEqual(t, c, d, "%s", k) - } -} diff --git a/oauth2/equalKeys_test.go b/oauth2/equalKeys_test.go deleted file mode 100644 index 13243a94bf3..00000000000 --- a/oauth2/equalKeys_test.go +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright © 2022 Ory Corp -// SPDX-License-Identifier: Apache-2.0 - -package oauth2 - -import "testing" - -func TestAssertObjectsAreEqualByKeys(t *testing.T) { - type foo struct { - Name string - Body int - } - a := &foo{"foo", 1} - b := &foo{"bar", 1} - c := &foo{"baz", 3} - - AssertObjectKeysEqual(t, a, a, "Name", "Body") - AssertObjectKeysNotEqual(t, a, b, "Name") - AssertObjectKeysNotEqual(t, a, c, "Name", "Body") -} diff --git a/oauth2/fosite_store_helpers.go b/oauth2/fosite_store_helpers_test.go similarity index 65% rename from oauth2/fosite_store_helpers.go rename to oauth2/fosite_store_helpers_test.go index 553a6bae62b..1084e31629c 100644 --- a/oauth2/fosite_store_helpers.go +++ b/oauth2/fosite_store_helpers_test.go @@ -1,85 +1,41 @@ // Copyright © 2022 Ory Corp // SPDX-License-Identifier: Apache-2.0 -package oauth2 +package oauth2_test import ( "context" - "crypto/sha256" "fmt" "net/url" "slices" "testing" "time" - "github.com/ory/x/assertx" - - "github.com/ory/hydra/v2/flow" - "github.com/ory/hydra/v2/jwk" + "github.com/ory/hydra/v2/persistence/sql" "github.com/go-jose/go-jose/v3" - "github.com/gobuffalo/pop/v6" - "github.com/pborman/uuid" - - "github.com/ory/fosite/handler/rfc7523" - - "github.com/ory/hydra/v2/oauth2/trust" - - "github.com/ory/hydra/v2/driver/config" - "github.com/ory/hydra/v2/x" - - "github.com/ory/fosite/storage" - "github.com/ory/x/sqlxx" - gofrsuuid "github.com/gofrs/uuid" + "github.com/pborman/uuid" "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/ory/fosite" "github.com/ory/fosite/handler/openid" - "github.com/ory/x/sqlcon" - + "github.com/ory/fosite/handler/rfc7523" + "github.com/ory/fosite/storage" "github.com/ory/hydra/v2/client" + "github.com/ory/hydra/v2/driver/config" + "github.com/ory/hydra/v2/flow" + "github.com/ory/hydra/v2/jwk" + "github.com/ory/hydra/v2/oauth2" + "github.com/ory/hydra/v2/oauth2/trust" + "github.com/ory/hydra/v2/x" + "github.com/ory/x/assertx" + "github.com/ory/x/sqlcon" + "github.com/ory/x/sqlxx" ) -func signatureFromJTI(jti string) string { - return fmt.Sprintf("%x", sha256.Sum256([]byte(jti))) -} - -type BlacklistedJTI struct { - JTI string `db:"-"` - ID string `db:"signature"` - Expiry time.Time `db:"expires_at"` - NID gofrsuuid.UUID `db:"nid"` -} - -func (j *BlacklistedJTI) AfterFind(_ *pop.Connection) error { - j.Expiry = j.Expiry.UTC() - return nil -} - -func (BlacklistedJTI) TableName() string { - return "hydra_oauth2_jti_blacklist" -} - -func NewBlacklistedJTI(jti string, exp time.Time) *BlacklistedJTI { - return &BlacklistedJTI{ - JTI: jti, - ID: signatureFromJTI(jti), - // because the database timestamp types are not as accurate as time.Time we truncate to seconds (which should always work) - Expiry: exp.UTC().Truncate(time.Second), - } -} - -type AssertionJWTReader interface { - x.FositeStorer - - GetClientAssertionJWT(ctx context.Context, jti string) (*BlacklistedJTI, error) - - SetClientAssertionJWTRaw(context.Context, *BlacklistedJTI) error -} - var defaultIgnoreKeys = []string{ "id", "session", @@ -94,29 +50,33 @@ var defaultIgnoreKeys = []string{ "client.client_secret", } -var defaultRequest = fosite.Request{ - ID: "blank", - RequestedAt: time.Now().UTC().Round(time.Second), - Client: &client.Client{ - ID: "foobar", - Contacts: []string{}, - RedirectURIs: []string{}, - Audience: []string{}, - AllowedCORSOrigins: []string{}, - ResponseTypes: []string{}, - GrantTypes: []string{}, - JSONWebKeys: &x.JoseJSONWebKeySet{}, - Metadata: sqlxx.JSONRawMessage("{}"), - }, - RequestedScope: fosite.Arguments{"fa", "ba"}, - GrantedScope: fosite.Arguments{"fa", "ba"}, - RequestedAudience: fosite.Arguments{"ad1", "ad2"}, - GrantedAudience: fosite.Arguments{"ad1", "ad2"}, - Form: url.Values{"foo": []string{"bar", "baz"}}, - Session: NewSession("bar"), +func newDefaultRequest(id string) fosite.Request { + return fosite.Request{ + ID: id, + RequestedAt: time.Now().UTC().Round(time.Second), + Client: &client.Client{ + ID: "foobar", + Contacts: []string{}, + RedirectURIs: []string{}, + Audience: []string{}, + AllowedCORSOrigins: []string{}, + ResponseTypes: []string{}, + GrantTypes: []string{}, + JSONWebKeys: &x.JoseJSONWebKeySet{}, + Metadata: sqlxx.JSONRawMessage("{}"), + }, + RequestedScope: fosite.Arguments{"fa", "ba"}, + GrantedScope: fosite.Arguments{"fa", "ba"}, + RequestedAudience: fosite.Arguments{"ad1", "ad2"}, + GrantedAudience: fosite.Arguments{"ad1", "ad2"}, + Form: url.Values{"foo": []string{"bar", "baz"}}, + Session: oauth2.NewSession("bar"), + } } -var lifespan = time.Hour +var defaultRequest = newDefaultRequest("blank") + +// var lifespan = time.Hour var flushRequests = []*fosite.Request{ { ID: "flush-1", @@ -125,7 +85,7 @@ var flushRequests = []*fosite.Request{ RequestedScope: fosite.Arguments{"fa", "ba"}, GrantedScope: fosite.Arguments{"fa", "ba"}, Form: url.Values{"foo": []string{"bar", "baz"}}, - Session: &Session{DefaultSession: &openid.DefaultSession{Subject: "bar"}}, + Session: &oauth2.Session{DefaultSession: &openid.DefaultSession{Subject: "bar"}}, }, { ID: "flush-2", @@ -134,7 +94,7 @@ var flushRequests = []*fosite.Request{ RequestedScope: fosite.Arguments{"fa", "ba"}, GrantedScope: fosite.Arguments{"fa", "ba"}, Form: url.Values{"foo": []string{"bar", "baz"}}, - Session: &Session{DefaultSession: &openid.DefaultSession{Subject: "bar"}}, + Session: &oauth2.Session{DefaultSession: &openid.DefaultSession{Subject: "bar"}}, }, { ID: "flush-3", @@ -143,11 +103,11 @@ var flushRequests = []*fosite.Request{ RequestedScope: fosite.Arguments{"fa", "ba"}, GrantedScope: fosite.Arguments{"fa", "ba"}, Form: url.Values{"foo": []string{"bar", "baz"}}, - Session: &Session{DefaultSession: &openid.DefaultSession{Subject: "bar"}}, + Session: &oauth2.Session{DefaultSession: &openid.DefaultSession{Subject: "bar"}}, }, } -func mockRequestForeignKey(t *testing.T, id string, x InternalRegistry) { +func mockRequestForeignKey(t *testing.T, id string, x oauth2.InternalRegistry) { cl := &client.Client{ID: "foobar"} cr := &flow.OAuth2ConsentRequest{ Client: cl, @@ -193,43 +153,10 @@ func mockRequestForeignKey(t *testing.T, id string, x InternalRegistry) { require.NoError(t, err) } -// TestHelperRunner is used to run the database suite of tests in this package. -// KEEP EXPORTED AND AVAILABLE FOR THIRD PARTIES TO TEST PLUGINS! -func TestHelperRunner(t *testing.T, store InternalRegistry, k string) { - t.Helper() - if k != "memory" { - t.Run(fmt.Sprintf("case=testHelperUniqueConstraints/db=%s", k), testHelperRequestIDMultiples(store, k)) - t.Run("case=testFositeSqlStoreTransactionsCommitAccessToken", testFositeSqlStoreTransactionCommitAccessToken(store)) - t.Run("case=testFositeSqlStoreTransactionsRollbackAccessToken", testFositeSqlStoreTransactionRollbackAccessToken(store)) - t.Run("case=testFositeSqlStoreTransactionCommitRefreshToken", testFositeSqlStoreTransactionCommitRefreshToken(store)) - t.Run("case=testFositeSqlStoreTransactionRollbackRefreshToken", testFositeSqlStoreTransactionRollbackRefreshToken(store)) - t.Run("case=testFositeSqlStoreTransactionCommitAuthorizeCode", testFositeSqlStoreTransactionCommitAuthorizeCode(store)) - t.Run("case=testFositeSqlStoreTransactionRollbackAuthorizeCode", testFositeSqlStoreTransactionRollbackAuthorizeCode(store)) - t.Run("case=testFositeSqlStoreTransactionCommitPKCERequest", testFositeSqlStoreTransactionCommitPKCERequest(store)) - t.Run("case=testFositeSqlStoreTransactionRollbackPKCERequest", testFositeSqlStoreTransactionRollbackPKCERequest(store)) - t.Run("case=testFositeSqlStoreTransactionCommitOpenIdConnectSession", testFositeSqlStoreTransactionCommitOpenIdConnectSession(store)) - t.Run("case=testFositeSqlStoreTransactionRollbackOpenIdConnectSession", testFositeSqlStoreTransactionRollbackOpenIdConnectSession(store)) - - } - t.Run(fmt.Sprintf("case=testHelperCreateGetDeleteAuthorizeCodes/db=%s", k), testHelperCreateGetDeleteAuthorizeCodes(store)) - t.Run(fmt.Sprintf("case=testHelperExpiryFields/db=%s", k), testHelperExpiryFields(store)) - t.Run(fmt.Sprintf("case=testHelperCreateGetDeleteAccessTokenSession/db=%s", k), testHelperCreateGetDeleteAccessTokenSession(store)) - t.Run(fmt.Sprintf("case=testHelperNilAccessToken/db=%s", k), testHelperNilAccessToken(store)) - t.Run(fmt.Sprintf("case=testHelperCreateGetDeleteOpenIDConnectSession/db=%s", k), testHelperCreateGetDeleteOpenIDConnectSession(store)) - t.Run(fmt.Sprintf("case=testHelperCreateGetDeleteRefreshTokenSession/db=%s", k), testHelperCreateGetDeleteRefreshTokenSession(store)) - t.Run(fmt.Sprintf("case=testHelperRevokeRefreshToken/db=%s", k), testHelperRevokeRefreshToken(store)) - t.Run(fmt.Sprintf("case=testHelperCreateGetDeletePKCERequestSession/db=%s", k), testHelperCreateGetDeletePKCERequestSession(store)) - t.Run(fmt.Sprintf("case=testHelperFlushTokens/db=%s", k), testHelperFlushTokens(store, time.Hour)) - t.Run(fmt.Sprintf("case=testHelperFlushTokensWithLimitAndBatchSize/db=%s", k), testHelperFlushTokensWithLimitAndBatchSize(store, 3, 2)) - t.Run(fmt.Sprintf("case=testFositeStoreSetClientAssertionJWT/db=%s", k), testFositeStoreSetClientAssertionJWT(store)) - t.Run(fmt.Sprintf("case=testFositeStoreClientAssertionJWTValid/db=%s", k), testFositeStoreClientAssertionJWTValid(store)) - t.Run(fmt.Sprintf("case=testHelperDeleteAccessTokens/db=%s", k), testHelperDeleteAccessTokens(store)) - t.Run(fmt.Sprintf("case=testHelperRevokeAccessToken/db=%s", k), testHelperRevokeAccessToken(store)) - t.Run(fmt.Sprintf("case=testFositeJWTBearerGrantStorage/db=%s", k), testFositeJWTBearerGrantStorage(store)) - t.Run(fmt.Sprintf("case=testHelperRevokeRefreshTokenMaybeGracePeriod/db=%s", k), testHelperRevokeRefreshTokenMaybeGracePeriod(store)) +func TestHelperRunner(t *testing.T) { } -func testHelperRequestIDMultiples(m InternalRegistry, _ string) func(t *testing.T) { +func testHelperRequestIDMultiples(m oauth2.InternalRegistry, _ string) func(t *testing.T) { return func(t *testing.T) { ctx := context.Background() requestID := uuid.New() @@ -240,12 +167,13 @@ func testHelperRequestIDMultiples(m InternalRegistry, _ string) func(t *testing. ID: requestID, Client: cl, RequestedAt: time.Now().UTC().Round(time.Second), - Session: NewSession("bar"), + Session: oauth2.NewSession("bar"), } for i := 0; i < 4; i++ { signature := uuid.New() - err := m.OAuth2Storage().CreateRefreshTokenSession(ctx, signature, fositeRequest) + accessSignature := uuid.New() + err := m.OAuth2Storage().CreateRefreshTokenSession(ctx, signature, accessSignature, fositeRequest) assert.NoError(t, err) err = m.OAuth2Storage().CreateAccessTokenSession(ctx, signature, fositeRequest) assert.NoError(t, err) @@ -259,58 +187,60 @@ func testHelperRequestIDMultiples(m InternalRegistry, _ string) func(t *testing. } } -func testHelperCreateGetDeleteOpenIDConnectSession(x InternalRegistry) func(t *testing.T) { +func testHelperCreateGetDeleteOpenIDConnectSession(x oauth2.InternalRegistry) func(t *testing.T) { return func(t *testing.T) { m := x.OAuth2Storage() + code := uuid.New() ctx := context.Background() - _, err := m.GetOpenIDConnectSession(ctx, "4321", &fosite.Request{Session: NewSession("bar")}) + _, err := m.GetOpenIDConnectSession(ctx, code, &fosite.Request{Session: oauth2.NewSession("bar")}) assert.NotNil(t, err) - err = m.CreateOpenIDConnectSession(ctx, "4321", &defaultRequest) + err = m.CreateOpenIDConnectSession(ctx, code, &defaultRequest) require.NoError(t, err) - res, err := m.GetOpenIDConnectSession(ctx, "4321", &fosite.Request{Session: NewSession("bar")}) + res, err := m.GetOpenIDConnectSession(ctx, code, &fosite.Request{Session: oauth2.NewSession("bar")}) require.NoError(t, err) AssertObjectKeysEqual(t, &defaultRequest, res, "RequestedScope", "GrantedScope", "Form", "Session") - err = m.DeleteOpenIDConnectSession(ctx, "4321") + err = m.DeleteOpenIDConnectSession(ctx, code) require.NoError(t, err) - _, err = m.GetOpenIDConnectSession(ctx, "4321", &fosite.Request{Session: NewSession("bar")}) + _, err = m.GetOpenIDConnectSession(ctx, code, &fosite.Request{Session: oauth2.NewSession("bar")}) assert.NotNil(t, err) } } -func testHelperCreateGetDeleteRefreshTokenSession(x InternalRegistry) func(t *testing.T) { +func testHelperCreateGetDeleteRefreshTokenSession(x oauth2.InternalRegistry) func(t *testing.T) { return func(t *testing.T) { m := x.OAuth2Storage() + code := uuid.New() ctx := context.Background() - _, err := m.GetRefreshTokenSession(ctx, "4321", NewSession("bar")) + _, err := m.GetRefreshTokenSession(ctx, code, oauth2.NewSession("bar")) assert.NotNil(t, err) - err = m.CreateRefreshTokenSession(ctx, "4321", &defaultRequest) + err = m.CreateRefreshTokenSession(ctx, code, "", &defaultRequest) require.NoError(t, err) - res, err := m.GetRefreshTokenSession(ctx, "4321", NewSession("bar")) + res, err := m.GetRefreshTokenSession(ctx, code, oauth2.NewSession("bar")) require.NoError(t, err) AssertObjectKeysEqual(t, &defaultRequest, res, "RequestedScope", "GrantedScope", "Form", "Session") - err = m.DeleteRefreshTokenSession(ctx, "4321") + err = m.DeleteRefreshTokenSession(ctx, code) require.NoError(t, err) - _, err = m.GetRefreshTokenSession(ctx, "4321", NewSession("bar")) + _, err = m.GetRefreshTokenSession(ctx, code, oauth2.NewSession("bar")) assert.NotNil(t, err) } } -func testHelperRevokeRefreshToken(x InternalRegistry) func(t *testing.T) { +func testHelperRevokeRefreshToken(x oauth2.InternalRegistry) func(t *testing.T) { return func(t *testing.T) { m := x.OAuth2Storage() ctx := context.Background() - _, err := m.GetRefreshTokenSession(ctx, "1111", NewSession("bar")) + _, err := m.GetRefreshTokenSession(ctx, "1111", oauth2.NewSession("bar")) assert.Error(t, err) reqIdOne := uuid.New() @@ -319,23 +249,23 @@ func testHelperRevokeRefreshToken(x InternalRegistry) func(t *testing.T) { mockRequestForeignKey(t, reqIdOne, x) mockRequestForeignKey(t, reqIdTwo, x) - err = m.CreateRefreshTokenSession(ctx, "1111", &fosite.Request{ + err = m.CreateRefreshTokenSession(ctx, "1111", "", &fosite.Request{ ID: reqIdOne, Client: &client.Client{ID: "foobar"}, RequestedAt: time.Now().UTC().Round(time.Second), - Session: NewSession("user"), + Session: oauth2.NewSession("user"), }) require.NoError(t, err) - err = m.CreateRefreshTokenSession(ctx, "1122", &fosite.Request{ + err = m.CreateRefreshTokenSession(ctx, "1122", "", &fosite.Request{ ID: reqIdTwo, Client: &client.Client{ID: "foobar"}, RequestedAt: time.Now().UTC().Round(time.Second), - Session: NewSession("user"), + Session: oauth2.NewSession("user"), }) require.NoError(t, err) - _, err = m.GetRefreshTokenSession(ctx, "1111", NewSession("bar")) + _, err = m.GetRefreshTokenSession(ctx, "1111", oauth2.NewSession("bar")) require.NoError(t, err) err = m.RevokeRefreshToken(ctx, reqIdOne) @@ -344,39 +274,40 @@ func testHelperRevokeRefreshToken(x InternalRegistry) func(t *testing.T) { err = m.RevokeRefreshToken(ctx, reqIdTwo) require.NoError(t, err) - req, err := m.GetRefreshTokenSession(ctx, "1111", NewSession("bar")) - assert.NotNil(t, req) - assert.EqualError(t, err, fosite.ErrInactiveToken.Error()) - - req, err = m.GetRefreshTokenSession(ctx, "1122", NewSession("bar")) - assert.NotNil(t, req) - assert.EqualError(t, err, fosite.ErrInactiveToken.Error()) + req, err := m.GetRefreshTokenSession(ctx, "1111", oauth2.NewSession("bar")) + assert.Nil(t, req) + assert.EqualError(t, err, fosite.ErrNotFound.Error()) + req, err = m.GetRefreshTokenSession(ctx, "1122", oauth2.NewSession("bar")) + assert.Nil(t, req) + assert.EqualError(t, err, fosite.ErrNotFound.Error()) } } -func testHelperCreateGetDeleteAuthorizeCodes(x InternalRegistry) func(t *testing.T) { +func testHelperCreateGetDeleteAuthorizeCodes(x oauth2.InternalRegistry) func(t *testing.T) { return func(t *testing.T) { m := x.OAuth2Storage() mockRequestForeignKey(t, "blank", x) + code := uuid.New() + ctx := context.Background() - res, err := m.GetAuthorizeCodeSession(ctx, "4321", NewSession("bar")) + res, err := m.GetAuthorizeCodeSession(ctx, code, oauth2.NewSession("bar")) assert.Error(t, err) assert.Nil(t, res) - err = m.CreateAuthorizeCodeSession(ctx, "4321", &defaultRequest) + err = m.CreateAuthorizeCodeSession(ctx, code, &defaultRequest) require.NoError(t, err) - res, err = m.GetAuthorizeCodeSession(ctx, "4321", NewSession("bar")) + res, err = m.GetAuthorizeCodeSession(ctx, code, oauth2.NewSession("bar")) require.NoError(t, err) AssertObjectKeysEqual(t, &defaultRequest, res, "RequestedScope", "GrantedScope", "Form", "Session") - err = m.InvalidateAuthorizeCodeSession(ctx, "4321") + err = m.InvalidateAuthorizeCodeSession(ctx, code) require.NoError(t, err) - res, err = m.GetAuthorizeCodeSession(ctx, "4321", NewSession("bar")) + res, err = m.GetAuthorizeCodeSession(ctx, code, oauth2.NewSession("bar")) require.Error(t, err) assert.EqualError(t, err, fosite.ErrInvalidatedAuthorizeCode.Error()) assert.NotNil(t, res) @@ -392,7 +323,7 @@ func (r testHelperExpiryFieldsResult) TableName() string { return "hydra_oauth2_" + r.name } -func testHelperExpiryFields(reg InternalRegistry) func(t *testing.T) { +func testHelperExpiryFields(reg oauth2.InternalRegistry) func(t *testing.T) { return func(t *testing.T) { m := reg.OAuth2Storage() t.Parallel() @@ -401,7 +332,7 @@ func testHelperExpiryFields(reg InternalRegistry) func(t *testing.T) { ctx := context.Background() - s := NewSession("bar") + s := oauth2.NewSession("bar") s.SetExpiresAt(fosite.AccessToken, time.Now().Add(time.Hour).Round(time.Minute)) s.SetExpiresAt(fosite.RefreshToken, time.Now().Add(time.Hour*2).Round(time.Minute)) s.SetExpiresAt(fosite.AuthorizeCode, time.Now().Add(time.Hour*3).Round(time.Minute)) @@ -433,7 +364,7 @@ func testHelperExpiryFields(reg InternalRegistry) func(t *testing.T) { t.Run("case=CreateRefreshTokenSession", func(t *testing.T) { id := uuid.New() - err := m.CreateRefreshTokenSession(ctx, id, &request) + err := m.CreateRefreshTokenSession(ctx, id, "", &request) require.NoError(t, err) r := testHelperExpiryFieldsResult{name: "refresh"} @@ -473,12 +404,12 @@ func testHelperExpiryFields(reg InternalRegistry) func(t *testing.T) { } } -func testHelperNilAccessToken(x InternalRegistry) func(t *testing.T) { +func testHelperNilAccessToken(x oauth2.InternalRegistry) func(t *testing.T) { return func(t *testing.T) { m := x.OAuth2Storage() - c := &client.Client{ID: "nil-request-client-id-123"} + c := &client.Client{ID: uuid.New()} require.NoError(t, x.ClientManager().CreateClient(context.Background(), c)) - err := m.CreateAccessTokenSession(context.Background(), "nil-request-id", &fosite.Request{ + err := m.CreateAccessTokenSession(context.Background(), uuid.New(), &fosite.Request{ ID: "", RequestedAt: time.Now().UTC().Round(time.Second), Client: c, @@ -487,158 +418,251 @@ func testHelperNilAccessToken(x InternalRegistry) func(t *testing.T) { RequestedAudience: fosite.Arguments{"ad1", "ad2"}, GrantedAudience: fosite.Arguments{"ad1", "ad2"}, Form: url.Values{"foo": []string{"bar", "baz"}}, - Session: NewSession("bar"), + Session: oauth2.NewSession("bar"), }) require.NoError(t, err) } } -func testHelperCreateGetDeleteAccessTokenSession(x InternalRegistry) func(t *testing.T) { +func testHelperCreateGetDeleteAccessTokenSession(x oauth2.InternalRegistry) func(t *testing.T) { return func(t *testing.T) { m := x.OAuth2Storage() + code := uuid.New() ctx := context.Background() - _, err := m.GetAccessTokenSession(ctx, "4321", NewSession("bar")) + _, err := m.GetAccessTokenSession(ctx, code, oauth2.NewSession("bar")) assert.Error(t, err) - err = m.CreateAccessTokenSession(ctx, "4321", &defaultRequest) + err = m.CreateAccessTokenSession(ctx, code, &defaultRequest) require.NoError(t, err) - res, err := m.GetAccessTokenSession(ctx, "4321", NewSession("bar")) + res, err := m.GetAccessTokenSession(ctx, code, oauth2.NewSession("bar")) require.NoError(t, err) AssertObjectKeysEqual(t, &defaultRequest, res, "RequestedScope", "GrantedScope", "Form", "Session") - err = m.DeleteAccessTokenSession(ctx, "4321") + err = m.DeleteAccessTokenSession(ctx, code) require.NoError(t, err) - _, err = m.GetAccessTokenSession(ctx, "4321", NewSession("bar")) + _, err = m.GetAccessTokenSession(ctx, code, oauth2.NewSession("bar")) assert.Error(t, err) } } -func testHelperDeleteAccessTokens(x InternalRegistry) func(t *testing.T) { +func testHelperDeleteAccessTokens(x oauth2.InternalRegistry) func(t *testing.T) { return func(t *testing.T) { m := x.OAuth2Storage() ctx := context.Background() - err := m.CreateAccessTokenSession(ctx, "4321", &defaultRequest) + code := uuid.New() + err := m.CreateAccessTokenSession(ctx, code, &defaultRequest) require.NoError(t, err) - _, err = m.GetAccessTokenSession(ctx, "4321", NewSession("bar")) + _, err = m.GetAccessTokenSession(ctx, code, oauth2.NewSession("bar")) require.NoError(t, err) err = m.DeleteAccessTokens(ctx, defaultRequest.Client.GetID()) require.NoError(t, err) - req, err := m.GetAccessTokenSession(ctx, "4321", NewSession("bar")) + req, err := m.GetAccessTokenSession(ctx, code, oauth2.NewSession("bar")) assert.Nil(t, req) assert.EqualError(t, err, fosite.ErrNotFound.Error()) } } -func testHelperRevokeAccessToken(x InternalRegistry) func(t *testing.T) { +func testHelperRevokeAccessToken(x oauth2.InternalRegistry) func(t *testing.T) { return func(t *testing.T) { m := x.OAuth2Storage() ctx := context.Background() - err := m.CreateAccessTokenSession(ctx, "4321", &defaultRequest) + code := uuid.New() + err := m.CreateAccessTokenSession(ctx, code, &defaultRequest) require.NoError(t, err) - _, err = m.GetAccessTokenSession(ctx, "4321", NewSession("bar")) + _, err = m.GetAccessTokenSession(ctx, code, oauth2.NewSession("bar")) require.NoError(t, err) err = m.RevokeAccessToken(ctx, defaultRequest.GetID()) require.NoError(t, err) - req, err := m.GetAccessTokenSession(ctx, "4321", NewSession("bar")) + req, err := m.GetAccessTokenSession(ctx, code, oauth2.NewSession("bar")) assert.Nil(t, req) assert.EqualError(t, err, fosite.ErrNotFound.Error()) } } -func testHelperRevokeRefreshTokenMaybeGracePeriod(x InternalRegistry) func(t *testing.T) { +func testHelperRotateRefreshToken(x oauth2.InternalRegistry) func(t *testing.T) { return func(t *testing.T) { ctx := context.Background() + createTokens := func(t *testing.T, r *fosite.Request) (refreshTokenSession string, accessTokenSession string) { + refreshTokenSession = fmt.Sprintf("refresh_token_%s", uuid.New()) + accessTokenSession = fmt.Sprintf("access_token_%s", uuid.New()) + err := x.OAuth2Storage().CreateAccessTokenSession(ctx, accessTokenSession, r) + require.NoError(t, err) + + err = x.OAuth2Storage().CreateRefreshTokenSession(ctx, refreshTokenSession, accessTokenSession, r) + require.NoError(t, err) + + // Sanity check + req, err := x.OAuth2Storage().GetRefreshTokenSession(ctx, refreshTokenSession, nil) + require.NoError(t, err) + require.EqualValues(t, r.GetID(), req.GetID()) + + req, err = x.OAuth2Storage().GetAccessTokenSession(ctx, accessTokenSession, nil) + require.NoError(t, err) + require.EqualValues(t, r.GetID(), req.GetID()) + return + } + t.Run("Revokes refresh token when grace period not configured", func(t *testing.T) { - // SETUP m := x.OAuth2Storage() + r := newDefaultRequest(uuid.New()) + refreshTokenSession, accessTokenSession := createTokens(t, &r) - refreshTokenSession := fmt.Sprintf("refresh_token_%d", time.Now().Unix()) - err := m.CreateRefreshTokenSession(ctx, refreshTokenSession, &defaultRequest) - require.NoError(t, err, "precondition failed: could not create refresh token session") - - // ACT - err = m.RevokeRefreshTokenMaybeGracePeriod(ctx, defaultRequest.GetID(), refreshTokenSession) + err := m.RotateRefreshToken(ctx, r.GetID(), refreshTokenSession) require.NoError(t, err) - tmpSession := new(fosite.Session) - _, err = m.GetRefreshTokenSession(ctx, refreshTokenSession, *tmpSession) + _, err = m.GetAccessTokenSession(ctx, accessTokenSession, nil) + assert.ErrorIs(t, err, fosite.ErrNotFound, "Token is no longer active because it was refreshed") + + _, err = m.GetRefreshTokenSession(ctx, refreshTokenSession, nil) + assert.ErrorIs(t, err, fosite.ErrInactiveToken, "Token is no longer active because it was refreshed") + }) + + t.Run("refresh token is valid until the grace period has ended", func(t *testing.T) { + x.Config().MustSet(ctx, config.KeyRefreshTokenRotationGracePeriod, "1s") + + // By setting this to one hour we ensure that using the refresh token triggers the start of the grace period. + x.Config().MustSet(ctx, config.KeyRefreshTokenLifespan, "1h") + t.Cleanup(func() { + x.Config().Delete(ctx, config.KeyRefreshTokenRotationGracePeriod) + }) + + m := x.OAuth2Storage() + r := newDefaultRequest(uuid.New()) + refreshTokenSession, accessTokenSession1 := createTokens(t, &r) + accessTokenSession2 := fmt.Sprintf("access_token_%s", uuid.New()) + require.NoError(t, m.CreateAccessTokenSession(ctx, accessTokenSession2, &r)) + + // Create a second access token + require.NoError(t, m.RotateRefreshToken(ctx, r.GetID(), refreshTokenSession)) + require.NoError(t, m.RotateRefreshToken(ctx, r.GetID(), refreshTokenSession)) + require.NoError(t, m.RotateRefreshToken(ctx, r.GetID(), refreshTokenSession)) + + req, err := m.GetAccessTokenSession(ctx, accessTokenSession1, nil) + assert.ErrorIs(t, err, fosite.ErrNotFound) + + req, err = m.GetAccessTokenSession(ctx, accessTokenSession2, nil) + assert.NoError(t, err, "The second access token is still valid.") + + req, err = m.GetRefreshTokenSession(ctx, refreshTokenSession, nil) + assert.NoError(t, err) + assert.Equal(t, r.GetID(), req.GetID()) - // ASSERT - // a revoked refresh token returns an error when getting the token again - assert.ErrorIs(t, err, fosite.ErrInactiveToken) + // We only wait a second, meaning that the token is theoretically still within TTL, but since the + // grace period was issued, the token is still valid. + time.Sleep(time.Second * 2) + req, err = m.GetRefreshTokenSession(ctx, refreshTokenSession, nil) + assert.Error(t, err) }) - t.Run("refresh token enters grace period when configured,", func(t *testing.T) { - // SETUP - x.Config().MustSet(ctx, config.KeyRefreshTokenRotationGracePeriod, "1m") + t.Run("the used at time does not change", func(t *testing.T) { + x.Config().MustSet(ctx, config.KeyRefreshTokenRotationGracePeriod, "1s") + + // By setting this to one hour we ensure that using the refresh token triggers the start of the grace period. + x.Config().MustSet(ctx, config.KeyRefreshTokenLifespan, "1h") + t.Cleanup(func() { + x.Config().Delete(ctx, config.KeyRefreshTokenRotationGracePeriod) + }) + + m := x.OAuth2Storage() + r := newDefaultRequest(uuid.New()) + + refreshTokenSession, _ := createTokens(t, &r) + require.NoError(t, m.RotateRefreshToken(ctx, r.GetID(), refreshTokenSession)) + + var expected sql.OAuth2RefreshTable + require.NoError(t, x.Persister().Connection(ctx).Where("signature=?", refreshTokenSession).First(&expected)) + assert.False(t, expected.FirstUsedAt.Time.IsZero()) + assert.True(t, expected.FirstUsedAt.Valid) + + // Refresh does not change the time + time.Sleep(time.Second * 2) + require.NoError(t, m.RotateRefreshToken(ctx, r.GetID(), refreshTokenSession)) + + var actual sql.OAuth2RefreshTable + require.NoError(t, x.Persister().Connection(ctx).Where("signature=?", refreshTokenSession).First(&actual)) + assert.Equal(t, expected.FirstUsedAt.Time, actual.FirstUsedAt.Time) + }) - // always reset back to the default + t.Run("refresh token revokes all access tokens from the request if the access token signature is not found", func(t *testing.T) { + x.Config().MustSet(ctx, config.KeyRefreshTokenRotationGracePeriod, "1s") t.Cleanup(func() { - x.Config().MustSet(ctx, config.KeyRefreshTokenRotationGracePeriod, "0m") + x.Config().Delete(ctx, config.KeyRefreshTokenRotationGracePeriod) }) m := x.OAuth2Storage() + r := newDefaultRequest(uuid.New()) - refreshTokenSession := fmt.Sprintf("refresh_token_%d_with_grace_period", time.Now().Unix()) - err := m.CreateRefreshTokenSession(ctx, refreshTokenSession, &defaultRequest) - require.NoError(t, err, "precondition failed: could not create refresh token session") + refreshTokenSession := fmt.Sprintf("refresh_token_%s", uuid.New()) + accessTokenSession1 := fmt.Sprintf("access_token_%s", uuid.New()) + accessTokenSession2 := fmt.Sprintf("access_token_%s", uuid.New()) + require.NoError(t, m.CreateAccessTokenSession(ctx, accessTokenSession1, &r)) + require.NoError(t, m.CreateAccessTokenSession(ctx, accessTokenSession2, &r)) + + require.NoError(t, m.CreateRefreshTokenSession(ctx, refreshTokenSession, "", &r), + "precondition failed: could not create refresh token session") // ACT - require.NoError(t, m.RevokeRefreshTokenMaybeGracePeriod(ctx, defaultRequest.GetID(), refreshTokenSession)) - require.NoError(t, m.RevokeRefreshTokenMaybeGracePeriod(ctx, defaultRequest.GetID(), refreshTokenSession)) - require.NoError(t, m.RevokeRefreshTokenMaybeGracePeriod(ctx, defaultRequest.GetID(), refreshTokenSession)) + require.NoError(t, m.RotateRefreshToken(ctx, r.GetID(), refreshTokenSession)) + require.NoError(t, m.RotateRefreshToken(ctx, r.GetID(), refreshTokenSession)) + require.NoError(t, m.RotateRefreshToken(ctx, r.GetID(), refreshTokenSession)) + + req, err := m.GetAccessTokenSession(ctx, accessTokenSession1, nil) + assert.ErrorIs(t, err, fosite.ErrNotFound) - req, err := m.GetRefreshTokenSession(ctx, refreshTokenSession, nil) + req, err = m.GetAccessTokenSession(ctx, accessTokenSession2, nil) + assert.ErrorIs(t, err, fosite.ErrNotFound) - // ASSERT - // when grace period is configured the refresh token can be obtained within - // the grace period without error + req, err = m.GetRefreshTokenSession(ctx, refreshTokenSession, nil) assert.NoError(t, err) + assert.Equal(t, r.GetID(), req.GetID()) + + time.Sleep(time.Second * 2) - assert.Equal(t, defaultRequest.GetID(), req.GetID()) + req, err = m.GetRefreshTokenSession(ctx, refreshTokenSession, nil) + assert.Error(t, err) }) } - } -func testHelperCreateGetDeletePKCERequestSession(x InternalRegistry) func(t *testing.T) { +func testHelperCreateGetDeletePKCERequestSession(x oauth2.InternalRegistry) func(t *testing.T) { return func(t *testing.T) { m := x.OAuth2Storage() + code := uuid.New() ctx := context.Background() - _, err := m.GetPKCERequestSession(ctx, "4321", NewSession("bar")) + _, err := m.GetPKCERequestSession(ctx, code, oauth2.NewSession("bar")) assert.NotNil(t, err) - err = m.CreatePKCERequestSession(ctx, "4321", &defaultRequest) + err = m.CreatePKCERequestSession(ctx, code, &defaultRequest) require.NoError(t, err) - res, err := m.GetPKCERequestSession(ctx, "4321", NewSession("bar")) + res, err := m.GetPKCERequestSession(ctx, code, oauth2.NewSession("bar")) require.NoError(t, err) AssertObjectKeysEqual(t, &defaultRequest, res, "RequestedScope", "GrantedScope", "Form", "Session") - err = m.DeletePKCERequestSession(ctx, "4321") + err = m.DeletePKCERequestSession(ctx, code) require.NoError(t, err) - _, err = m.GetPKCERequestSession(ctx, "4321", NewSession("bar")) + _, err = m.GetPKCERequestSession(ctx, code, oauth2.NewSession("bar")) assert.NotNil(t, err) } } -func testHelperFlushTokens(x InternalRegistry, lifespan time.Duration) func(t *testing.T) { +func testHelperFlushTokens(x oauth2.InternalRegistry, lifespan time.Duration) func(t *testing.T) { m := x.OAuth2Storage() - ds := &Session{} + ds := &oauth2.Session{} return func(t *testing.T) { ctx := context.Background() @@ -676,9 +700,9 @@ func testHelperFlushTokens(x InternalRegistry, lifespan time.Duration) func(t *t } } -func testHelperFlushTokensWithLimitAndBatchSize(x InternalRegistry, limit int, batchSize int) func(t *testing.T) { +func testHelperFlushTokensWithLimitAndBatchSize(x oauth2.InternalRegistry, limit int, batchSize int) func(t *testing.T) { m := x.OAuth2Storage() - ds := &Session{} + ds := &oauth2.Session{} return func(t *testing.T) { ctx := context.Background() @@ -712,7 +736,7 @@ func testHelperFlushTokensWithLimitAndBatchSize(x InternalRegistry, limit int, b } } -func testFositeSqlStoreTransactionCommitAccessToken(m InternalRegistry) func(t *testing.T) { +func testFositeSqlStoreTransactionCommitAccessToken(m oauth2.InternalRegistry) func(t *testing.T) { return func(t *testing.T) { { doTestCommit(m, t, m.OAuth2Storage().CreateAccessTokenSession, m.OAuth2Storage().GetAccessTokenSession, m.OAuth2Storage().RevokeAccessToken) @@ -721,7 +745,7 @@ func testFositeSqlStoreTransactionCommitAccessToken(m InternalRegistry) func(t * } } -func testFositeSqlStoreTransactionRollbackAccessToken(m InternalRegistry) func(t *testing.T) { +func testFositeSqlStoreTransactionRollbackAccessToken(m oauth2.InternalRegistry) func(t *testing.T) { return func(t *testing.T) { { doTestRollback(m, t, m.OAuth2Storage().CreateAccessTokenSession, m.OAuth2Storage().GetAccessTokenSession, m.OAuth2Storage().RevokeAccessToken) @@ -730,42 +754,41 @@ func testFositeSqlStoreTransactionRollbackAccessToken(m InternalRegistry) func(t } } -func testFositeSqlStoreTransactionCommitRefreshToken(m InternalRegistry) func(t *testing.T) { - +func testFositeSqlStoreTransactionCommitRefreshToken(m oauth2.InternalRegistry) func(t *testing.T) { return func(t *testing.T) { - doTestCommit(m, t, m.OAuth2Storage().CreateRefreshTokenSession, m.OAuth2Storage().GetRefreshTokenSession, m.OAuth2Storage().RevokeRefreshToken) - doTestCommit(m, t, m.OAuth2Storage().CreateRefreshTokenSession, m.OAuth2Storage().GetRefreshTokenSession, m.OAuth2Storage().DeleteRefreshTokenSession) + doTestCommitRefresh(m, t, m.OAuth2Storage().CreateRefreshTokenSession, m.OAuth2Storage().GetRefreshTokenSession, m.OAuth2Storage().RevokeRefreshToken) + doTestCommitRefresh(m, t, m.OAuth2Storage().CreateRefreshTokenSession, m.OAuth2Storage().GetRefreshTokenSession, m.OAuth2Storage().DeleteRefreshTokenSession) } } -func testFositeSqlStoreTransactionRollbackRefreshToken(m InternalRegistry) func(t *testing.T) { +func testFositeSqlStoreTransactionRollbackRefreshToken(m oauth2.InternalRegistry) func(t *testing.T) { return func(t *testing.T) { - doTestRollback(m, t, m.OAuth2Storage().CreateRefreshTokenSession, m.OAuth2Storage().GetRefreshTokenSession, m.OAuth2Storage().RevokeRefreshToken) - doTestRollback(m, t, m.OAuth2Storage().CreateRefreshTokenSession, m.OAuth2Storage().GetRefreshTokenSession, m.OAuth2Storage().DeleteRefreshTokenSession) + doTestRollbackRefresh(m, t, m.OAuth2Storage().CreateRefreshTokenSession, m.OAuth2Storage().GetRefreshTokenSession, m.OAuth2Storage().RevokeRefreshToken) + doTestRollbackRefresh(m, t, m.OAuth2Storage().CreateRefreshTokenSession, m.OAuth2Storage().GetRefreshTokenSession, m.OAuth2Storage().DeleteRefreshTokenSession) } } -func testFositeSqlStoreTransactionCommitAuthorizeCode(m InternalRegistry) func(t *testing.T) { +func testFositeSqlStoreTransactionCommitAuthorizeCode(m oauth2.InternalRegistry) func(t *testing.T) { return func(t *testing.T) { doTestCommit(m, t, m.OAuth2Storage().CreateAuthorizeCodeSession, m.OAuth2Storage().GetAuthorizeCodeSession, m.OAuth2Storage().InvalidateAuthorizeCodeSession) } } -func testFositeSqlStoreTransactionRollbackAuthorizeCode(m InternalRegistry) func(t *testing.T) { +func testFositeSqlStoreTransactionRollbackAuthorizeCode(m oauth2.InternalRegistry) func(t *testing.T) { return func(t *testing.T) { doTestRollback(m, t, m.OAuth2Storage().CreateAuthorizeCodeSession, m.OAuth2Storage().GetAuthorizeCodeSession, m.OAuth2Storage().InvalidateAuthorizeCodeSession) } } -func testFositeSqlStoreTransactionCommitPKCERequest(m InternalRegistry) func(t *testing.T) { +func testFositeSqlStoreTransactionCommitPKCERequest(m oauth2.InternalRegistry) func(t *testing.T) { return func(t *testing.T) { doTestCommit(m, t, m.OAuth2Storage().CreatePKCERequestSession, m.OAuth2Storage().GetPKCERequestSession, m.OAuth2Storage().DeletePKCERequestSession) } } -func testFositeSqlStoreTransactionRollbackPKCERequest(m InternalRegistry) func(t *testing.T) { +func testFositeSqlStoreTransactionRollbackPKCERequest(m oauth2.InternalRegistry) func(t *testing.T) { return func(t *testing.T) { doTestRollback(m, t, m.OAuth2Storage().CreatePKCERequestSession, m.OAuth2Storage().GetPKCERequestSession, m.OAuth2Storage().DeletePKCERequestSession) } @@ -773,7 +796,7 @@ func testFositeSqlStoreTransactionRollbackPKCERequest(m InternalRegistry) func(t // OpenIdConnect tests can't use the helper functions, due to the signature of GetOpenIdConnectSession being // different from the other getter methods -func testFositeSqlStoreTransactionCommitOpenIdConnectSession(m InternalRegistry) func(t *testing.T) { +func testFositeSqlStoreTransactionCommitOpenIdConnectSession(m oauth2.InternalRegistry) func(t *testing.T) { return func(t *testing.T) { txnStore, ok := m.OAuth2Storage().(storage.Transactional) require.True(t, ok) @@ -808,7 +831,7 @@ func testFositeSqlStoreTransactionCommitOpenIdConnectSession(m InternalRegistry) } } -func testFositeSqlStoreTransactionRollbackOpenIdConnectSession(m InternalRegistry) func(t *testing.T) { +func testFositeSqlStoreTransactionRollbackOpenIdConnectSession(m oauth2.InternalRegistry) func(t *testing.T) { return func(t *testing.T) { txnStore, ok := m.OAuth2Storage().(storage.Transactional) require.True(t, ok) @@ -849,12 +872,12 @@ func testFositeSqlStoreTransactionRollbackOpenIdConnectSession(m InternalRegistr } } -func testFositeStoreSetClientAssertionJWT(m InternalRegistry) func(*testing.T) { +func testFositeStoreSetClientAssertionJWT(m oauth2.InternalRegistry) func(*testing.T) { return func(t *testing.T) { t.Run("case=basic setting works", func(t *testing.T) { - store, ok := m.OAuth2Storage().(AssertionJWTReader) + store, ok := m.OAuth2Storage().(oauth2.AssertionJWTReader) require.True(t, ok) - jti := NewBlacklistedJTI("basic jti", time.Now().Add(time.Minute)) + jti := oauth2.NewBlacklistedJTI(uuid.New(), time.Now().Add(time.Minute)) require.NoError(t, store.SetClientAssertionJWT(context.Background(), jti.JTI, jti.Expiry)) @@ -866,20 +889,20 @@ func testFositeStoreSetClientAssertionJWT(m InternalRegistry) func(*testing.T) { }) t.Run("case=errors when the JTI is blacklisted", func(t *testing.T) { - store, ok := m.OAuth2Storage().(AssertionJWTReader) + store, ok := m.OAuth2Storage().(oauth2.AssertionJWTReader) require.True(t, ok) - jti := NewBlacklistedJTI("already set jti", time.Now().Add(time.Minute)) + jti := oauth2.NewBlacklistedJTI(uuid.New(), time.Now().Add(time.Minute)) require.NoError(t, store.SetClientAssertionJWTRaw(context.Background(), jti)) assert.ErrorIs(t, store.SetClientAssertionJWT(context.Background(), jti.JTI, jti.Expiry), fosite.ErrJTIKnown) }) t.Run("case=deletes expired JTIs", func(t *testing.T) { - store, ok := m.OAuth2Storage().(AssertionJWTReader) + store, ok := m.OAuth2Storage().(oauth2.AssertionJWTReader) require.True(t, ok) - expiredJTI := NewBlacklistedJTI("expired jti", time.Now().Add(-time.Minute)) + expiredJTI := oauth2.NewBlacklistedJTI(uuid.New(), time.Now().Add(-time.Minute)) require.NoError(t, store.SetClientAssertionJWTRaw(context.Background(), expiredJTI)) - newJTI := NewBlacklistedJTI("some new jti", time.Now().Add(time.Minute)) + newJTI := oauth2.NewBlacklistedJTI(uuid.New(), time.Now().Add(time.Minute)) require.NoError(t, store.SetClientAssertionJWT(context.Background(), newJTI.JTI, newJTI.Expiry)) @@ -893,9 +916,9 @@ func testFositeStoreSetClientAssertionJWT(m InternalRegistry) func(*testing.T) { }) t.Run("case=inserts same JTI if expired", func(t *testing.T) { - store, ok := m.OAuth2Storage().(AssertionJWTReader) + store, ok := m.OAuth2Storage().(oauth2.AssertionJWTReader) require.True(t, ok) - jti := NewBlacklistedJTI("going to be reused jti", time.Now().Add(-time.Minute)) + jti := oauth2.NewBlacklistedJTI(uuid.New(), time.Now().Add(-time.Minute)) require.NoError(t, store.SetClientAssertionJWTRaw(context.Background(), jti)) jti.Expiry = jti.Expiry.Add(2 * time.Minute) @@ -907,19 +930,19 @@ func testFositeStoreSetClientAssertionJWT(m InternalRegistry) func(*testing.T) { } } -func testFositeStoreClientAssertionJWTValid(m InternalRegistry) func(*testing.T) { +func testFositeStoreClientAssertionJWTValid(m oauth2.InternalRegistry) func(*testing.T) { return func(t *testing.T) { t.Run("case=returns valid on unknown JTI", func(t *testing.T) { - store, ok := m.OAuth2Storage().(AssertionJWTReader) + store, ok := m.OAuth2Storage().(oauth2.AssertionJWTReader) require.True(t, ok) - assert.NoError(t, store.ClientAssertionJWTValid(context.Background(), "unknown jti")) + assert.NoError(t, store.ClientAssertionJWTValid(context.Background(), uuid.New())) }) t.Run("case=returns invalid on known JTI", func(t *testing.T) { - store, ok := m.OAuth2Storage().(AssertionJWTReader) + store, ok := m.OAuth2Storage().(oauth2.AssertionJWTReader) require.True(t, ok) - jti := NewBlacklistedJTI("known jti", time.Now().Add(time.Minute)) + jti := oauth2.NewBlacklistedJTI(uuid.New(), time.Now().Add(time.Minute)) require.NoError(t, store.SetClientAssertionJWTRaw(context.Background(), jti)) @@ -927,9 +950,9 @@ func testFositeStoreClientAssertionJWTValid(m InternalRegistry) func(*testing.T) }) t.Run("case=returns valid on expired JTI", func(t *testing.T) { - store, ok := m.OAuth2Storage().(AssertionJWTReader) + store, ok := m.OAuth2Storage().(oauth2.AssertionJWTReader) require.True(t, ok) - jti := NewBlacklistedJTI("expired jti 2", time.Now().Add(-time.Minute)) + jti := oauth2.NewBlacklistedJTI(uuid.New(), time.Now().Add(-time.Minute)) require.NoError(t, store.SetClientAssertionJWTRaw(context.Background(), jti)) @@ -938,7 +961,7 @@ func testFositeStoreClientAssertionJWTValid(m InternalRegistry) func(*testing.T) } } -func testFositeJWTBearerGrantStorage(x InternalRegistry) func(t *testing.T) { +func testFositeJWTBearerGrantStorage(x oauth2.InternalRegistry) func(t *testing.T) { return func(t *testing.T) { ctx := context.Background() grantManager := x.GrantManager() @@ -946,12 +969,12 @@ func testFositeJWTBearerGrantStorage(x InternalRegistry) func(t *testing.T) { grantStorage := x.OAuth2Storage().(rfc7523.RFC7523KeyStorage) t.Run("case=associated key added with grant", func(t *testing.T) { - keySet, err := jwk.GenerateJWK(context.Background(), jose.RS256, "token-service-key", "sig") + keySet, err := jwk.GenerateJWK(context.Background(), jose.RS256, uuid.New(), "sig") require.NoError(t, err) publicKey := keySet.Keys[0].Public() - issuer := "token-service" - subject := "bob@example.com" + issuer := uuid.New() + subject := "bob+" + uuid.New() + "@example.com" grant := trust.Grant{ ID: uuid.New(), Issuer: issuer, @@ -992,14 +1015,14 @@ func testFositeJWTBearerGrantStorage(x InternalRegistry) func(t *testing.T) { }) t.Run("case=only associated key returns", func(t *testing.T) { - keySetToNotReturn, err := jwk.GenerateJWK(context.Background(), jose.ES256, "some-key", "sig") + keySetToNotReturn, err := jwk.GenerateJWK(context.Background(), jose.ES256, uuid.New(), "sig") require.NoError(t, err) - require.NoError(t, keyManager.AddKeySet(context.Background(), "some-set", keySetToNotReturn), "adding a random key should not fail") + require.NoError(t, keyManager.AddKeySet(context.Background(), uuid.New(), keySetToNotReturn), "adding a random key should not fail") - issuer := "maria" - subject := "maria@example.com" + issuer := uuid.New() + subject := "maria+" + uuid.New() + "@example.com" - keySet1ToReturn, err := jwk.GenerateJWK(context.Background(), jose.ES256, "maria-key-1", "sig") + keySet1ToReturn, err := jwk.GenerateJWK(context.Background(), jose.ES256, uuid.New(), "sig") require.NoError(t, err) require.NoError(t, grantManager.CreateGrant(context.Background(), trust.Grant{ ID: uuid.New(), @@ -1012,7 +1035,7 @@ func testFositeJWTBearerGrantStorage(x InternalRegistry) func(t *testing.T) { ExpiresAt: time.Now().UTC().Round(time.Second).AddDate(1, 0, 0), }, keySet1ToReturn.Keys[0].Public())) - keySet2ToReturn, err := jwk.GenerateJWK(context.Background(), jose.ES256, "maria-key-2", "sig") + keySet2ToReturn, err := jwk.GenerateJWK(context.Background(), jose.ES256, uuid.New(), "sig") require.NoError(t, err) require.NoError(t, grantManager.CreateGrant(ctx, trust.Grant{ ID: uuid.New(), @@ -1055,12 +1078,12 @@ func testFositeJWTBearerGrantStorage(x InternalRegistry) func(t *testing.T) { }) t.Run("case=associated key is deleted, when granted is deleted", func(t *testing.T) { - keySet, err := jwk.GenerateJWK(context.Background(), jose.RS256, "hackerman-key", "sig") + keySet, err := jwk.GenerateJWK(context.Background(), jose.RS256, uuid.New(), "sig") require.NoError(t, err) publicKey := keySet.Keys[0].Public() - issuer := "aeneas" - subject := "aeneas@example.com" + issuer := uuid.New() + subject := "aeneas+" + uuid.New() + "@example.com" grant := trust.Grant{ ID: uuid.New(), Issuer: issuer, @@ -1092,12 +1115,12 @@ func testFositeJWTBearerGrantStorage(x InternalRegistry) func(t *testing.T) { }) t.Run("case=associated grant is deleted, when key is deleted", func(t *testing.T) { - keySet, err := jwk.GenerateJWK(context.Background(), jose.RS256, "vladimir-key", "sig") + keySet, err := jwk.GenerateJWK(context.Background(), jose.RS256, uuid.New(), "sig") require.NoError(t, err) publicKey := keySet.Keys[0].Public() - issuer := "vladimir" - subject := "vladimir@example.com" + issuer := uuid.New() + subject := "vladimir+" + uuid.New() + "@example.com" grant := trust.Grant{ ID: uuid.New(), Issuer: issuer, @@ -1129,12 +1152,12 @@ func testFositeJWTBearerGrantStorage(x InternalRegistry) func(t *testing.T) { }) t.Run("case=only returns the key when subject matches", func(t *testing.T) { - keySet, err := jwk.GenerateJWK(context.Background(), jose.RS256, "issuer-key", "sig") + keySet, err := jwk.GenerateJWK(context.Background(), jose.RS256, uuid.New(), "sig") require.NoError(t, err) publicKey := keySet.Keys[0].Public() - issuer := "limited-issuer" - subject := "jagoba" + issuer := uuid.New() + subject := "jagoba+" + uuid.New() + "@example.com" grant := trust.Grant{ ID: uuid.New(), Issuer: issuer, @@ -1171,11 +1194,11 @@ func testFositeJWTBearerGrantStorage(x InternalRegistry) func(t *testing.T) { }) t.Run("case=returns the key when any subject is allowed", func(t *testing.T) { - keySet, err := jwk.GenerateJWK(context.Background(), jose.RS256, "issuer-key", "sig") + keySet, err := jwk.GenerateJWK(context.Background(), jose.RS256, uuid.New(), "sig") require.NoError(t, err) publicKey := keySet.Keys[0].Public() - issuer := "unlimited-issuer" + issuer := uuid.New() grant := trust.Grant{ ID: uuid.New(), Issuer: issuer, @@ -1204,11 +1227,11 @@ func testFositeJWTBearerGrantStorage(x InternalRegistry) func(t *testing.T) { }) t.Run("case=does not return expired values", func(t *testing.T) { - keySet, err := jwk.GenerateJWK(context.Background(), jose.RS256, "issuer-expired-key", "sig") + keySet, err := jwk.GenerateJWK(context.Background(), jose.RS256, uuid.New(), "sig") require.NoError(t, err) publicKey := keySet.Keys[0].Public() - issuer := "expired-issuer" + issuer := uuid.New() grant := trust.Grant{ ID: uuid.New(), Issuer: issuer, @@ -1230,12 +1253,11 @@ func testFositeJWTBearerGrantStorage(x InternalRegistry) func(t *testing.T) { } } -func doTestCommit(m InternalRegistry, t *testing.T, +func doTestCommit(m oauth2.InternalRegistry, t *testing.T, createFn func(context.Context, string, fosite.Requester) error, getFn func(context.Context, string, fosite.Session) (fosite.Requester, error), revokeFn func(context.Context, string) error, ) { - txnStore, ok := m.OAuth2Storage().(storage.Transactional) require.True(t, ok) ctx := context.Background() @@ -1248,7 +1270,44 @@ func doTestCommit(m InternalRegistry, t *testing.T, require.NoError(t, err) // Require a new context, since the old one contains the transaction. - res, err := getFn(context.Background(), signature, NewSession("bar")) + res, err := getFn(context.Background(), signature, oauth2.NewSession("bar")) + // token should have been created successfully because Commit did not return an error + require.NoError(t, err) + assertx.EqualAsJSONExcept(t, &defaultRequest, res, defaultIgnoreKeys) + // AssertObjectKeysEqual(t, &defaultRequest, res, "RequestedScope", "GrantedScope", "Form", "Session") + + // testrevoke within a transaction + ctx, err = txnStore.BeginTX(context.Background()) + require.NoError(t, err) + err = revokeFn(ctx, signature) + require.NoError(t, err) + err = txnStore.Commit(ctx) + require.NoError(t, err) + + // Require a new context, since the old one contains the transaction. + _, err = getFn(context.Background(), signature, oauth2.NewSession("bar")) + // Since commit worked for revoke, we should get an error here. + require.Error(t, err) +} + +func doTestCommitRefresh(m oauth2.InternalRegistry, t *testing.T, + createFn func(context.Context, string, string, fosite.Requester) error, + getFn func(context.Context, string, fosite.Session) (fosite.Requester, error), + revokeFn func(context.Context, string) error, +) { + txnStore, ok := m.OAuth2Storage().(storage.Transactional) + require.True(t, ok) + ctx := context.Background() + ctx, err := txnStore.BeginTX(ctx) + require.NoError(t, err) + signature := uuid.New() + err = createFn(ctx, signature, "", createTestRequest(signature)) + require.NoError(t, err) + err = txnStore.Commit(ctx) + require.NoError(t, err) + + // Require a new context, since the old one contains the transaction. + res, err := getFn(context.Background(), signature, oauth2.NewSession("bar")) // token should have been created successfully because Commit did not return an error require.NoError(t, err) assertx.EqualAsJSONExcept(t, &defaultRequest, res, defaultIgnoreKeys) @@ -1263,12 +1322,12 @@ func doTestCommit(m InternalRegistry, t *testing.T, require.NoError(t, err) // Require a new context, since the old one contains the transaction. - _, err = getFn(context.Background(), signature, NewSession("bar")) + _, err = getFn(context.Background(), signature, oauth2.NewSession("bar")) // Since commit worked for revoke, we should get an error here. require.Error(t, err) } -func doTestRollback(m InternalRegistry, t *testing.T, +func doTestRollback(m oauth2.InternalRegistry, t *testing.T, createFn func(context.Context, string, fosite.Requester) error, getFn func(context.Context, string, fosite.Session) (fosite.Requester, error), revokeFn func(context.Context, string) error, @@ -1287,7 +1346,7 @@ func doTestRollback(m InternalRegistry, t *testing.T, // Require a new context, since the old one contains the transaction. ctx = context.Background() - _, err = getFn(ctx, signature, NewSession("bar")) + _, err = getFn(ctx, signature, oauth2.NewSession("bar")) // Since we rolled back above, the token should not exist and getting it should result in an error require.Error(t, err) @@ -1295,7 +1354,48 @@ func doTestRollback(m InternalRegistry, t *testing.T, signature2 := uuid.New() err = createFn(ctx, signature2, createTestRequest(signature2)) require.NoError(t, err) - _, err = getFn(ctx, signature2, NewSession("bar")) + _, err = getFn(ctx, signature2, oauth2.NewSession("bar")) + require.NoError(t, err) + + ctx, err = txnStore.BeginTX(context.Background()) + require.NoError(t, err) + err = revokeFn(ctx, signature2) + require.NoError(t, err) + err = txnStore.Rollback(ctx) + require.NoError(t, err) + + _, err = getFn(context.Background(), signature2, oauth2.NewSession("bar")) + require.NoError(t, err) +} + +func doTestRollbackRefresh(m oauth2.InternalRegistry, t *testing.T, + createFn func(context.Context, string, string, fosite.Requester) error, + getFn func(context.Context, string, fosite.Session) (fosite.Requester, error), + revokeFn func(context.Context, string) error, +) { + txnStore, ok := m.OAuth2Storage().(storage.Transactional) + require.True(t, ok) + + ctx := context.Background() + ctx, err := txnStore.BeginTX(ctx) + require.NoError(t, err) + signature := uuid.New() + err = createFn(ctx, signature, "", createTestRequest(signature)) + require.NoError(t, err) + err = txnStore.Rollback(ctx) + require.NoError(t, err) + + // Require a new context, since the old one contains the transaction. + ctx = context.Background() + _, err = getFn(ctx, signature, oauth2.NewSession("bar")) + // Since we rolled back above, the token should not exist and getting it should result in an error + require.Error(t, err) + + // create a new token, revoke it, then rollback the revoke. We should be able to then get it successfully. + signature2 := uuid.New() + err = createFn(ctx, signature2, "", createTestRequest(signature2)) + require.NoError(t, err) + _, err = getFn(ctx, signature2, oauth2.NewSession("bar")) require.NoError(t, err) ctx, err = txnStore.BeginTX(context.Background()) @@ -1305,7 +1405,7 @@ func doTestRollback(m InternalRegistry, t *testing.T, err = txnStore.Rollback(ctx) require.NoError(t, err) - _, err = getFn(context.Background(), signature2, NewSession("bar")) + _, err = getFn(context.Background(), signature2, oauth2.NewSession("bar")) require.NoError(t, err) } @@ -1319,6 +1419,6 @@ func createTestRequest(id string) *fosite.Request { RequestedAudience: fosite.Arguments{"ad1", "ad2"}, GrantedAudience: fosite.Arguments{"ad1", "ad2"}, Form: url.Values{"foo": []string{"bar", "baz"}}, - Session: &Session{DefaultSession: &openid.DefaultSession{Subject: "bar"}}, + Session: &oauth2.Session{DefaultSession: &openid.DefaultSession{Subject: "bar"}}, } } diff --git a/oauth2/fosite_store_test.go b/oauth2/fosite_store_test.go index 2a48a52f8e7..9804704102c 100644 --- a/oauth2/fosite_store_test.go +++ b/oauth2/fosite_store_test.go @@ -7,16 +7,13 @@ import ( "context" "flag" "testing" + "time" - "github.com/stretchr/testify/require" + "github.com/ory/hydra/v2/internal/testhelpers" - "github.com/ory/hydra/v2/client" "github.com/ory/hydra/v2/driver" "github.com/ory/hydra/v2/driver/config" - "github.com/ory/hydra/v2/internal" - . "github.com/ory/hydra/v2/oauth2" "github.com/ory/x/contextx" - "github.com/ory/x/networkx" "github.com/ory/x/sqlcon/dockertest" ) @@ -29,7 +26,7 @@ func TestMain(m *testing.M) { var registries = make(map[string]driver.Registry) var cleanRegistries = func(t *testing.T) { - registries["memory"] = internal.NewRegistryMemory(t, internal.NewConfigurationWithDefaults(), &contextx.Default{}) + registries["memory"] = testhelpers.NewRegistryMemory(t, testhelpers.NewConfigurationWithDefaults(), &contextx.Default{}) } // returns clean registries that can safely be used for one test @@ -38,7 +35,7 @@ func setupRegistries(t *testing.T) { if len(registries) == 0 && !testing.Short() { // first time called and sql tests var cleanSQL func(*testing.T) - registries["postgres"], registries["mysql"], registries["cockroach"], cleanSQL = internal.ConnectDatabases(t, true, &contextx.Default{}) + registries["postgres"], registries["mysql"], registries["cockroach"], cleanSQL = testhelpers.ConnectDatabases(t, false, &contextx.Default{}) cleanMem := cleanRegistries cleanMem(t) cleanRegistries = func(t *testing.T) { @@ -52,6 +49,8 @@ func setupRegistries(t *testing.T) { } func TestManagers(t *testing.T) { + setupRegistries(t) + ctx := context.Background() tests := []struct { name string @@ -68,18 +67,43 @@ func TestManagers(t *testing.T) { } for _, tc := range tests { t.Run("suite="+tc.name, func(t *testing.T) { - setupRegistries(t) + for k, r := range registries { + t.Run("database="+k, func(t *testing.T) { + store := testhelpers.NewRegistrySQLFromURL(t, r.Config().DSN(), true, &contextx.Default{}) + store.Config().MustSet(ctx, config.KeyEncryptSessionData, tc.enableSessionEncrypted) - require.NoError(t, registries["memory"].ClientManager().CreateClient(context.Background(), &client.Client{ID: "foobar"})) // this is a workaround because the client is not being created for memory store by test helpers. + if k != "memory" { + t.Run("testHelperUniqueConstraints", testHelperRequestIDMultiples(store, k)) + t.Run("case=testFositeSqlStoreTransactionsCommitAccessToken", testFositeSqlStoreTransactionCommitAccessToken(store)) + t.Run("case=testFositeSqlStoreTransactionsRollbackAccessToken", testFositeSqlStoreTransactionRollbackAccessToken(store)) + t.Run("case=testFositeSqlStoreTransactionCommitRefreshToken", testFositeSqlStoreTransactionCommitRefreshToken(store)) + t.Run("case=testFositeSqlStoreTransactionRollbackRefreshToken", testFositeSqlStoreTransactionRollbackRefreshToken(store)) + t.Run("case=testFositeSqlStoreTransactionCommitAuthorizeCode", testFositeSqlStoreTransactionCommitAuthorizeCode(store)) + t.Run("case=testFositeSqlStoreTransactionRollbackAuthorizeCode", testFositeSqlStoreTransactionRollbackAuthorizeCode(store)) + t.Run("case=testFositeSqlStoreTransactionCommitPKCERequest", testFositeSqlStoreTransactionCommitPKCERequest(store)) + t.Run("case=testFositeSqlStoreTransactionRollbackPKCERequest", testFositeSqlStoreTransactionRollbackPKCERequest(store)) + t.Run("case=testFositeSqlStoreTransactionCommitOpenIdConnectSession", testFositeSqlStoreTransactionCommitOpenIdConnectSession(store)) + t.Run("case=testFositeSqlStoreTransactionRollbackOpenIdConnectSession", testFositeSqlStoreTransactionRollbackOpenIdConnectSession(store)) + } - for k, store := range registries { - net := &networkx.Network{} - require.NoError(t, store.Persister().Connection(context.Background()).First(net)) - store.Config().MustSet(ctx, config.KeyEncryptSessionData, tc.enableSessionEncrypted) - store.WithContextualizer(&contextx.Static{NID: net.ID, C: store.Config().Source(ctx)}) - TestHelperRunner(t, store, k) + t.Run("testHelperCreateGetDeleteAuthorizeCodes", testHelperCreateGetDeleteAuthorizeCodes(store)) + t.Run("testHelperExpiryFields", testHelperExpiryFields(store)) + t.Run("testHelperCreateGetDeleteAccessTokenSession", testHelperCreateGetDeleteAccessTokenSession(store)) + t.Run("testHelperNilAccessToken", testHelperNilAccessToken(store)) + t.Run("testHelperCreateGetDeleteOpenIDConnectSession", testHelperCreateGetDeleteOpenIDConnectSession(store)) + t.Run("testHelperCreateGetDeleteRefreshTokenSession", testHelperCreateGetDeleteRefreshTokenSession(store)) + t.Run("testHelperRevokeRefreshToken", testHelperRevokeRefreshToken(store)) + t.Run("testHelperCreateGetDeletePKCERequestSession", testHelperCreateGetDeletePKCERequestSession(store)) + t.Run("testHelperFlushTokens", testHelperFlushTokens(store, time.Hour)) + t.Run("testHelperFlushTokensWithLimitAndBatchSize", testHelperFlushTokensWithLimitAndBatchSize(store, 3, 2)) + t.Run("testFositeStoreSetClientAssertionJWT", testFositeStoreSetClientAssertionJWT(store)) + t.Run("testFositeStoreClientAssertionJWTValid", testFositeStoreClientAssertionJWTValid(store)) + t.Run("testHelperDeleteAccessTokens", testHelperDeleteAccessTokens(store)) + t.Run("testHelperRevokeAccessToken", testHelperRevokeAccessToken(store)) + t.Run("testFositeJWTBearerGrantStorage", testFositeJWTBearerGrantStorage(store)) + t.Run("testHelperRotateRefreshToken", testHelperRotateRefreshToken(store)) + }) } }) - } } diff --git a/oauth2/handler.go b/oauth2/handler.go index 288ed1f16f0..3f1a633038d 100644 --- a/oauth2/handler.go +++ b/oauth2/handler.go @@ -727,11 +727,14 @@ type revokeOAuth2Token struct { // default: errorOAuth2 func (h *Handler) revokeOAuth2Token(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - events.Trace(ctx, events.AccessTokenRevoked) - err := h.r.OAuth2Provider().NewRevocationRequest(ctx, r) + err := h.r.Persister().Transaction(ctx, func(ctx context.Context, _ *pop.Connection) error { + return h.r.OAuth2Provider().NewRevocationRequest(ctx, r) + }) if err != nil { x.LogError(r, err, h.r.Logger()) + } else { + events.Trace(ctx, events.AccessTokenRevoked) } h.r.OAuth2Provider().WriteRevocationResponse(ctx, w, err) diff --git a/oauth2/handler_fallback_endpoints_test.go b/oauth2/handler_fallback_endpoints_test.go index 191cd15a03a..9e3107b722b 100644 --- a/oauth2/handler_fallback_endpoints_test.go +++ b/oauth2/handler_fallback_endpoints_test.go @@ -10,22 +10,23 @@ import ( "net/http/httptest" "testing" + "github.com/ory/hydra/v2/internal/testhelpers" + "github.com/ory/x/httprouterx" "github.com/ory/hydra/v2/x" "github.com/ory/x/contextx" "github.com/ory/hydra/v2/driver/config" - "github.com/ory/hydra/v2/internal" "github.com/ory/hydra/v2/oauth2" "github.com/stretchr/testify/assert" ) func TestHandlerConsent(t *testing.T) { - conf := internal.NewConfigurationWithDefaults() + conf := testhelpers.NewConfigurationWithDefaults() conf.MustSet(context.Background(), config.KeyScopeStrategy, "DEPRECATED_HIERARCHICAL_SCOPE_STRATEGY") - reg := internal.NewRegistryMemory(t, conf, &contextx.Default{}) + reg := testhelpers.NewRegistryMemory(t, conf, &contextx.Default{}) h := reg.OAuth2Handler() r := x.NewRouterAdmin(conf.AdminURL) diff --git a/oauth2/handler_test.go b/oauth2/handler_test.go index f2d159af614..50705fad6bf 100644 --- a/oauth2/handler_test.go +++ b/oauth2/handler_test.go @@ -15,6 +15,8 @@ import ( "testing" "time" + "github.com/ory/hydra/v2/internal/testhelpers" + hydra "github.com/ory/hydra-client-go/v2" "github.com/ory/x/httprouterx" @@ -31,13 +33,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/ory/hydra/v2/driver/config" - "github.com/ory/hydra/v2/internal" - "github.com/ory/fosite" "github.com/ory/fosite/handler/openid" "github.com/ory/fosite/token/jwt" "github.com/ory/hydra/v2/client" + "github.com/ory/hydra/v2/driver/config" "github.com/ory/hydra/v2/oauth2" ) @@ -45,9 +45,9 @@ var lifespan = time.Hour func TestHandlerDeleteHandler(t *testing.T) { ctx := context.Background() - conf := internal.NewConfigurationWithDefaults() + conf := testhelpers.NewConfigurationWithDefaults() conf.MustSet(ctx, config.KeyIssuerURL, "http://hydra.localhost") - reg := internal.NewRegistryMemory(t, conf, &contextx.Default{}) + reg := testhelpers.NewRegistryMemory(t, conf, &contextx.Default{}) cm := reg.ClientManager() store := reg.OAuth2Storage() @@ -88,12 +88,12 @@ func TestHandlerDeleteHandler(t *testing.T) { func TestUserinfo(t *testing.T) { ctx := context.Background() - conf := internal.NewConfigurationWithDefaults() + conf := testhelpers.NewConfigurationWithDefaults() conf.MustSet(ctx, config.KeyScopeStrategy, "") conf.MustSet(ctx, config.KeyAuthCodeLifespan, lifespan) conf.MustSet(ctx, config.KeyIssuerURL, "http://hydra.localhost") - reg := internal.NewRegistryMemory(t, conf, &contextx.Default{}) - internal.MustEnsureRegistryKeys(ctx, reg, x.OpenIDConnectKeyName) + reg := testhelpers.NewRegistryMemory(t, conf, &contextx.Default{}) + testhelpers.MustEnsureRegistryKeys(ctx, reg, x.OpenIDConnectKeyName) ctrl := gomock.NewController(t) op := NewMockOAuth2Provider(ctrl) @@ -340,7 +340,7 @@ func TestUserinfo(t *testing.T) { func TestHandlerWellKnown(t *testing.T) { ctx := context.Background() - conf := internal.NewConfigurationWithDefaults() + conf := testhelpers.NewConfigurationWithDefaults() t.Run(fmt.Sprintf("hsm_enabled=%v", conf.HSMEnabled()), func(t *testing.T) { conf.MustSet(ctx, config.KeyScopeStrategy, "DEPRECATED_HIERARCHICAL_SCOPE_STRATEGY") conf.MustSet(ctx, config.KeyIssuerURL, "http://hydra.localhost") @@ -348,7 +348,7 @@ func TestHandlerWellKnown(t *testing.T) { conf.MustSet(ctx, config.KeyOIDCDiscoverySupportedClaims, []string{"sub"}) conf.MustSet(ctx, config.KeyOAuth2ClientRegistrationURL, "http://client-register/registration") conf.MustSet(ctx, config.KeyOIDCDiscoveryUserinfoEndpoint, "/userinfo") - reg := internal.NewRegistryMemory(t, conf, &contextx.Default{}) + reg := testhelpers.NewRegistryMemory(t, conf, &contextx.Default{}) h := oauth2.NewHandler(reg, conf) diff --git a/oauth2/helper_test.go b/oauth2/helper_test.go index 3a40592bfdd..04f41298b71 100644 --- a/oauth2/helper_test.go +++ b/oauth2/helper_test.go @@ -5,6 +5,11 @@ package oauth2_test import ( "context" + "testing" + + "github.com/oleiade/reflections" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/ory/fosite" "github.com/ory/fosite/handler/oauth2" @@ -20,3 +25,60 @@ func Tokens(c fosite.Configurator, length int) (res [][]string) { } return res } + +func AssertObjectKeysEqual(t *testing.T, a, b interface{}, keys ...string) { + assert.True(t, len(keys) > 0, "No keys provided.") + for _, k := range keys { + c, err := reflections.GetField(a, k) + assert.Nil(t, err) + d, err := reflections.GetField(b, k) + assert.Nil(t, err) + assert.Equal(t, c, d, "%s", k) + } +} + +func AssertObjectKeysNotEqual(t *testing.T, a, b interface{}, keys ...string) { + assert.True(t, len(keys) > 0, "No keys provided.") + for _, k := range keys { + c, err := reflections.GetField(a, k) + assert.Nil(t, err) + d, err := reflections.GetField(b, k) + assert.Nil(t, err) + assert.NotEqual(t, c, d, "%s", k) + } +} + +func RequireObjectKeysEqual(t *testing.T, a, b interface{}, keys ...string) { + assert.True(t, len(keys) > 0, "No keys provided.") + for _, k := range keys { + c, err := reflections.GetField(a, k) + assert.Nil(t, err) + d, err := reflections.GetField(b, k) + assert.Nil(t, err) + require.Equal(t, c, d, "%s", k) + } +} +func RequireObjectKeysNotEqual(t *testing.T, a, b interface{}, keys ...string) { + assert.True(t, len(keys) > 0, "No keys provided.") + for _, k := range keys { + c, err := reflections.GetField(a, k) + assert.Nil(t, err) + d, err := reflections.GetField(b, k) + assert.Nil(t, err) + require.NotEqual(t, c, d, "%s", k) + } +} + +func TestAssertObjectsAreEqualByKeys(t *testing.T) { + type foo struct { + Name string + Body int + } + a := &foo{"foo", 1} + b := &foo{"bar", 1} + c := &foo{"baz", 3} + + AssertObjectKeysEqual(t, a, a, "Name", "Body") + AssertObjectKeysNotEqual(t, a, b, "Name") + AssertObjectKeysNotEqual(t, a, c, "Name", "Body") +} diff --git a/oauth2/helpers.go b/oauth2/helpers.go new file mode 100644 index 00000000000..4db4bf84d8e --- /dev/null +++ b/oauth2/helpers.go @@ -0,0 +1,51 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oauth2 + +import ( + "context" + "crypto/sha256" + "fmt" + "time" + + "github.com/gobuffalo/pop/v6" + gofrsuuid "github.com/gofrs/uuid" + + "github.com/ory/hydra/v2/x" +) + +func signatureFromJTI(jti string) string { + return fmt.Sprintf("%x", sha256.Sum256([]byte(jti))) +} + +type BlacklistedJTI struct { + JTI string `db:"-"` + ID string `db:"signature"` + Expiry time.Time `db:"expires_at"` + NID gofrsuuid.UUID `db:"nid"` +} + +func (j *BlacklistedJTI) AfterFind(_ *pop.Connection) error { + j.Expiry = j.Expiry.UTC() + return nil +} + +func (BlacklistedJTI) TableName() string { + return "hydra_oauth2_jti_blacklist" +} + +func NewBlacklistedJTI(jti string, exp time.Time) *BlacklistedJTI { + return &BlacklistedJTI{ + JTI: jti, + ID: signatureFromJTI(jti), + // because the database timestamp types are not as accurate as time.Time we truncate to seconds (which should always work) + Expiry: exp.UTC().Truncate(time.Second), + } +} + +type AssertionJWTReader interface { + x.FositeStorer + GetClientAssertionJWT(ctx context.Context, jti string) (*BlacklistedJTI, error) + SetClientAssertionJWTRaw(context.Context, *BlacklistedJTI) error +} diff --git a/oauth2/introspector_test.go b/oauth2/introspector_test.go index 16b279f036f..43b565d2f58 100644 --- a/oauth2/introspector_test.go +++ b/oauth2/introspector_test.go @@ -12,6 +12,8 @@ import ( "testing" "time" + "github.com/ory/hydra/v2/internal/testhelpers" + hydra "github.com/ory/hydra-client-go/v2" "github.com/ory/x/httprouterx" @@ -30,12 +32,12 @@ import ( func TestIntrospectorSDK(t *testing.T) { ctx := context.Background() - conf := internal.NewConfigurationWithDefaults() + conf := testhelpers.NewConfigurationWithDefaults() conf.MustSet(ctx, config.KeyScopeStrategy, "wildcard") conf.MustSet(ctx, config.KeyIssuerURL, "https://foobariss") - reg := internal.NewRegistryMemory(t, conf, &contextx.Default{}) + reg := testhelpers.NewRegistryMemory(t, conf, &contextx.Default{}) - internal.MustEnsureRegistryKeys(ctx, reg, x.OpenIDConnectKeyName) + testhelpers.MustEnsureRegistryKeys(ctx, reg, x.OpenIDConnectKeyName) internal.AddFositeExamples(reg) tokens := Tokens(reg.OAuth2ProviderConfig(), 4) diff --git a/oauth2/oauth2_auth_code_bench_test.go b/oauth2/oauth2_auth_code_bench_test.go index 568ff00287c..9347982630a 100644 --- a/oauth2/oauth2_auth_code_bench_test.go +++ b/oauth2/oauth2_auth_code_bench_test.go @@ -33,7 +33,6 @@ import ( hydra "github.com/ory/hydra-client-go/v2" hc "github.com/ory/hydra/v2/client" "github.com/ory/hydra/v2/driver/config" - "github.com/ory/hydra/v2/internal" "github.com/ory/hydra/v2/internal/testhelpers" "github.com/ory/hydra/v2/jwk" "github.com/ory/hydra/v2/x" @@ -79,7 +78,7 @@ func BenchmarkAuthCode(b *testing.B) { dsn := stringsx.Coalesce(os.Getenv("DSN"), "postgres://postgres:secret@127.0.0.1:3445/postgres?sslmode=disable&max_conns=20&max_idle_conns=20") // dsn := "mysql://root:secret@tcp(localhost:3444)/mysql?max_conns=16&max_idle_conns=16" // dsn := "cockroach://root@localhost:3446/defaultdb?sslmode=disable&max_conns=16&max_idle_conns=16" - reg := internal.NewRegistrySQLFromURL(b, dsn, true, new(contextx.Default)).WithTracer(tracer) + reg := testhelpers.NewRegistrySQLFromURL(b, dsn, true, new(contextx.Default)).WithTracer(tracer) reg.Config().MustSet(ctx, config.KeyLogLevel, "error") reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "opaque") reg.Config().MustSet(ctx, config.KeyRefreshTokenHook, "") diff --git a/oauth2/oauth2_auth_code_test.go b/oauth2/oauth2_auth_code_test.go index feea2451e27..c74e5356570 100644 --- a/oauth2/oauth2_auth_code_test.go +++ b/oauth2/oauth2_auth_code_test.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "io" + "math/rand" "net/http" "net/http/httptest" "net/url" @@ -20,6 +21,8 @@ import ( "testing" "time" + "github.com/ory/hydra/v2/jwk" + "github.com/go-jose/go-jose/v3" "github.com/golang-jwt/jwt/v5" "github.com/julienschmidt/httprouter" @@ -36,7 +39,6 @@ import ( "github.com/ory/hydra/v2/driver" "github.com/ory/hydra/v2/driver/config" "github.com/ory/hydra/v2/flow" - "github.com/ory/hydra/v2/internal" "github.com/ory/hydra/v2/internal/testhelpers" hydraoauth2 "github.com/ory/hydra/v2/oauth2" "github.com/ory/hydra/v2/x" @@ -62,1299 +64,1483 @@ type clientCreator interface { CreateClient(context.Context, *client.Client) error } -// TestAuthCodeWithDefaultStrategy runs proper integration tests against in-memory and database connectors, specifically -// we test: -// -// - [x] If the flow - in general - works -// - [x] If `authenticatedAt` is properly managed across the lifecycle -// - [x] The value `authenticatedAt` should be an old time if no user interaction wrt login was required -// - [x] The value `authenticatedAt` should be a recent time if user interaction wrt login was required -// -// - [x] If `requestedAt` is properly managed across the lifecycle -// - [x] The value of `requestedAt` must be the initial request time, not some other time (e.g. when accepting login) -// -// - [x] If `id_token_hint` is handled properly -// - [x] What happens if `id_token_hint` does not match the value from the handled authentication request ("accept login") -func TestAuthCodeWithDefaultStrategy(t *testing.T) { - ctx := context.Background() - reg := internal.NewMockedRegistry(t, &contextx.Default{}) - reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "opaque") - reg.Config().MustSet(ctx, config.KeyRefreshTokenHook, "") - publicTS, adminTS := testhelpers.NewOAuth2Server(ctx, t, reg) - - publicClient := hydra.NewAPIClient(hydra.NewConfiguration()) - publicClient.GetConfig().Servers = hydra.ServerConfigurations{{URL: publicTS.URL}} - adminClient := hydra.NewAPIClient(hydra.NewConfiguration()) - adminClient.GetConfig().Servers = hydra.ServerConfigurations{{URL: adminTS.URL}} - - getAuthorizeCode := func(t *testing.T, conf *oauth2.Config, c *http.Client, params ...oauth2.AuthCodeOption) (string, *http.Response) { - if c == nil { - c = testhelpers.NewEmptyJarClient(t) - } +func getAuthorizeCode(t *testing.T, conf *oauth2.Config, c *http.Client, params ...oauth2.AuthCodeOption) (string, *http.Response) { + if c == nil { + c = testhelpers.NewEmptyJarClient(t) + } - state := uuid.New() - resp, err := c.Get(conf.AuthCodeURL(state, params...)) - require.NoError(t, err) - defer resp.Body.Close() + state := uuid.New() + resp, err := c.Get(conf.AuthCodeURL(state, params...)) + require.NoError(t, err) + defer resp.Body.Close() - q := resp.Request.URL.Query() - require.EqualValues(t, state, q.Get("state")) - return q.Get("code"), resp - } + q := resp.Request.URL.Query() + require.EqualValues(t, state, q.Get("state")) + return q.Get("code"), resp +} - acceptLoginHandler := func(t *testing.T, c *client.Client, subject string, checkRequestPayload func(request *hydra.OAuth2LoginRequest) *hydra.AcceptOAuth2LoginRequest) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - rr, _, err := adminClient.OAuth2API.GetOAuth2LoginRequest(context.Background()).LoginChallenge(r.URL.Query().Get("login_challenge")).Execute() - require.NoError(t, err) - - assert.EqualValues(t, c.GetID(), pointerx.Deref(rr.Client.ClientId)) - assert.Empty(t, pointerx.Deref(rr.Client.ClientSecret)) - assert.EqualValues(t, c.GrantTypes, rr.Client.GrantTypes) - assert.EqualValues(t, c.LogoURI, pointerx.Deref(rr.Client.LogoUri)) - assert.EqualValues(t, c.RedirectURIs, rr.Client.RedirectUris) - assert.EqualValues(t, r.URL.Query().Get("login_challenge"), rr.Challenge) - assert.EqualValues(t, []string{"hydra", "offline", "openid"}, rr.RequestedScope) - assert.Contains(t, rr.RequestUrl, reg.Config().OAuth2AuthURL(ctx).String()) - - acceptBody := hydra.AcceptOAuth2LoginRequest{ - Subject: subject, - Remember: pointerx.Ptr(!rr.Skip), - Acr: pointerx.Ptr("1"), - Amr: []string{"pwd"}, - Context: map[string]interface{}{"context": "bar"}, - } - if checkRequestPayload != nil { - if b := checkRequestPayload(rr); b != nil { - acceptBody = *b - } - } +func acceptLoginHandler(t *testing.T, c *client.Client, adminClient *hydra.APIClient, reg driver.Registry, subject string, checkRequestPayload func(request *hydra.OAuth2LoginRequest) *hydra.AcceptOAuth2LoginRequest) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + rr, _, err := adminClient.OAuth2API.GetOAuth2LoginRequest(context.Background()).LoginChallenge(r.URL.Query().Get("login_challenge")).Execute() + require.NoError(t, err) - v, _, err := adminClient.OAuth2API.AcceptOAuth2LoginRequest(context.Background()). - LoginChallenge(r.URL.Query().Get("login_challenge")). - AcceptOAuth2LoginRequest(acceptBody). - Execute() - require.NoError(t, err) - require.NotEmpty(t, v.RedirectTo) - http.Redirect(w, r, v.RedirectTo, http.StatusFound) + assert.EqualValues(t, c.GetID(), pointerx.Deref(rr.Client.ClientId)) + assert.Empty(t, pointerx.Deref(rr.Client.ClientSecret)) + assert.EqualValues(t, c.GrantTypes, rr.Client.GrantTypes) + assert.EqualValues(t, c.LogoURI, pointerx.Deref(rr.Client.LogoUri)) + assert.EqualValues(t, c.RedirectURIs, rr.Client.RedirectUris) + assert.EqualValues(t, r.URL.Query().Get("login_challenge"), rr.Challenge) + assert.EqualValues(t, []string{"hydra", "offline", "openid"}, rr.RequestedScope) + assert.Contains(t, rr.RequestUrl, reg.Config().OAuth2AuthURL(ctx).String()) + + acceptBody := hydra.AcceptOAuth2LoginRequest{ + Subject: subject, + Remember: pointerx.Ptr(!rr.Skip), + Acr: pointerx.Ptr("1"), + Amr: []string{"pwd"}, + Context: map[string]interface{}{"context": "bar"}, } - } - - acceptConsentHandler := func(t *testing.T, c *client.Client, subject string, checkRequestPayload func(*hydra.OAuth2ConsentRequest)) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - rr, _, err := adminClient.OAuth2API.GetOAuth2ConsentRequest(context.Background()).ConsentChallenge(r.URL.Query().Get("consent_challenge")).Execute() - require.NoError(t, err) - - assert.EqualValues(t, c.GetID(), pointerx.Deref(rr.Client.ClientId)) - assert.Empty(t, pointerx.Deref(rr.Client.ClientSecret)) - assert.EqualValues(t, c.GrantTypes, rr.Client.GrantTypes) - assert.EqualValues(t, c.LogoURI, pointerx.Deref(rr.Client.LogoUri)) - assert.EqualValues(t, c.RedirectURIs, rr.Client.RedirectUris) - assert.EqualValues(t, subject, pointerx.Deref(rr.Subject)) - assert.EqualValues(t, []string{"hydra", "offline", "openid"}, rr.RequestedScope) - assert.EqualValues(t, r.URL.Query().Get("consent_challenge"), rr.Challenge) - assert.Contains(t, *rr.RequestUrl, reg.Config().OAuth2AuthURL(ctx).String()) - if checkRequestPayload != nil { - checkRequestPayload(rr) + if checkRequestPayload != nil { + if b := checkRequestPayload(rr); b != nil { + acceptBody = *b } - - assert.Equal(t, map[string]interface{}{"context": "bar"}, rr.Context) - v, _, err := adminClient.OAuth2API.AcceptOAuth2ConsentRequest(context.Background()). - ConsentChallenge(r.URL.Query().Get("consent_challenge")). - AcceptOAuth2ConsentRequest(hydra.AcceptOAuth2ConsentRequest{ - GrantScope: []string{"hydra", "offline", "openid"}, Remember: pointerx.Ptr(true), RememberFor: pointerx.Ptr[int64](0), - GrantAccessTokenAudience: rr.RequestedAccessTokenAudience, - Session: &hydra.AcceptOAuth2ConsentRequestSession{ - AccessToken: map[string]interface{}{"foo": "bar"}, - IdToken: map[string]interface{}{"bar": "baz", "email": "foo@bar.com"}, - }, - }). - Execute() - require.NoError(t, err) - require.NotEmpty(t, v.RedirectTo) - http.Redirect(w, r, v.RedirectTo, http.StatusFound) } - } - - assertRefreshToken := func(t *testing.T, token *oauth2.Token, c *oauth2.Config, expectedExp time.Time) { - introspect := testhelpers.IntrospectToken(t, c, token.RefreshToken, adminTS) - actualExp, err := strconv.ParseInt(introspect.Get("exp").String(), 10, 64) - require.NoError(t, err, "%s", introspect) - requirex.EqualTime(t, expectedExp, time.Unix(actualExp, 0), time.Second) - } - assertIDToken := func(t *testing.T, token *oauth2.Token, c *oauth2.Config, expectedSubject, expectedNonce string, expectedExp time.Time) gjson.Result { - idt, ok := token.Extra("id_token").(string) - require.True(t, ok) - assert.NotEmpty(t, idt) - - body, err := x.DecodeSegment(strings.Split(idt, ".")[1]) + v, _, err := adminClient.OAuth2API.AcceptOAuth2LoginRequest(context.Background()). + LoginChallenge(r.URL.Query().Get("login_challenge")). + AcceptOAuth2LoginRequest(acceptBody). + Execute() require.NoError(t, err) - - claims := gjson.ParseBytes(body) - assert.True(t, time.Now().After(time.Unix(claims.Get("iat").Int(), 0)), "%s", claims) - assert.True(t, time.Now().After(time.Unix(claims.Get("nbf").Int(), 0)), "%s", claims) - assert.True(t, time.Now().Before(time.Unix(claims.Get("exp").Int(), 0)), "%s", claims) - requirex.EqualTime(t, expectedExp, time.Unix(claims.Get("exp").Int(), 0), 2*time.Second) - assert.NotEmpty(t, claims.Get("jti").String(), "%s", claims) - assert.EqualValues(t, reg.Config().IssuerURL(ctx).String(), claims.Get("iss").String(), "%s", claims) - assert.NotEmpty(t, claims.Get("sid").String(), "%s", claims) - assert.Equal(t, "1", claims.Get("acr").String(), "%s", claims) - require.Len(t, claims.Get("amr").Array(), 1, "%s", claims) - assert.EqualValues(t, "pwd", claims.Get("amr").Array()[0].String(), "%s", claims) - - require.Len(t, claims.Get("aud").Array(), 1, "%s", claims) - assert.EqualValues(t, c.ClientID, claims.Get("aud").Array()[0].String(), "%s", claims) - assert.EqualValues(t, expectedSubject, claims.Get("sub").String(), "%s", claims) - assert.EqualValues(t, expectedNonce, claims.Get("nonce").String(), "%s", claims) - assert.EqualValues(t, `baz`, claims.Get("bar").String(), "%s", claims) - assert.EqualValues(t, `foo@bar.com`, claims.Get("email").String(), "%s", claims) - assert.NotEmpty(t, claims.Get("sid").String(), "%s", claims) - - return claims + require.NotEmpty(t, v.RedirectTo) + http.Redirect(w, r, v.RedirectTo, http.StatusFound) } +} - introspectAccessToken := func(t *testing.T, conf *oauth2.Config, token *oauth2.Token, expectedSubject string) gjson.Result { - require.NotEmpty(t, token.AccessToken) - i := testhelpers.IntrospectToken(t, conf, token.AccessToken, adminTS) - assert.True(t, i.Get("active").Bool(), "%s", i) - assert.EqualValues(t, conf.ClientID, i.Get("client_id").String(), "%s", i) - assert.EqualValues(t, expectedSubject, i.Get("sub").String(), "%s", i) - assert.EqualValues(t, `bar`, i.Get("ext.foo").String(), "%s", i) - return i - } +func acceptConsentHandler(t *testing.T, c *client.Client, adminClient *hydra.APIClient, reg driver.Registry, subject string, checkRequestPayload func(*hydra.OAuth2ConsentRequest)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + rr, _, err := adminClient.OAuth2API.GetOAuth2ConsentRequest(context.Background()).ConsentChallenge(r.URL.Query().Get("consent_challenge")).Execute() + require.NoError(t, err) - assertJWTAccessToken := func(t *testing.T, strat string, conf *oauth2.Config, token *oauth2.Token, expectedSubject string, expectedExp time.Time, scopes string) gjson.Result { - require.NotEmpty(t, token.AccessToken) - parts := strings.Split(token.AccessToken, ".") - if strat != "jwt" { - require.Len(t, parts, 2) - return gjson.Parse("null") + assert.EqualValues(t, c.GetID(), pointerx.Deref(rr.Client.ClientId)) + assert.Empty(t, pointerx.Deref(rr.Client.ClientSecret)) + assert.EqualValues(t, c.GrantTypes, rr.Client.GrantTypes) + assert.EqualValues(t, c.LogoURI, pointerx.Deref(rr.Client.LogoUri)) + assert.EqualValues(t, c.RedirectURIs, rr.Client.RedirectUris) + assert.EqualValues(t, subject, pointerx.Deref(rr.Subject)) + assert.EqualValues(t, []string{"hydra", "offline", "openid"}, rr.RequestedScope) + assert.EqualValues(t, r.URL.Query().Get("consent_challenge"), rr.Challenge) + assert.Contains(t, *rr.RequestUrl, reg.Config().OAuth2AuthURL(r.Context()).String()) + if checkRequestPayload != nil { + checkRequestPayload(rr) } - require.Len(t, parts, 3) - body, err := x.DecodeSegment(parts[1]) + assert.Equal(t, map[string]interface{}{"context": "bar"}, rr.Context) + v, _, err := adminClient.OAuth2API.AcceptOAuth2ConsentRequest(context.Background()). + ConsentChallenge(r.URL.Query().Get("consent_challenge")). + AcceptOAuth2ConsentRequest(hydra.AcceptOAuth2ConsentRequest{ + GrantScope: []string{"hydra", "offline", "openid"}, Remember: pointerx.Ptr(true), RememberFor: pointerx.Ptr[int64](0), + GrantAccessTokenAudience: rr.RequestedAccessTokenAudience, + Session: &hydra.AcceptOAuth2ConsentRequestSession{ + AccessToken: map[string]interface{}{"foo": "bar"}, + IdToken: map[string]interface{}{"bar": "baz", "email": "foo@bar.com"}, + }, + }). + Execute() require.NoError(t, err) - - i := gjson.ParseBytes(body) - assert.NotEmpty(t, i.Get("jti").String()) - assert.EqualValues(t, conf.ClientID, i.Get("client_id").String(), "%s", i) - assert.EqualValues(t, expectedSubject, i.Get("sub").String(), "%s", i) - assert.EqualValues(t, reg.Config().IssuerURL(ctx).String(), i.Get("iss").String(), "%s", i) - assert.True(t, time.Now().After(time.Unix(i.Get("iat").Int(), 0)), "%s", i) - assert.True(t, time.Now().After(time.Unix(i.Get("nbf").Int(), 0)), "%s", i) - assert.True(t, time.Now().Before(time.Unix(i.Get("exp").Int(), 0)), "%s", i) - requirex.EqualTime(t, expectedExp, time.Unix(i.Get("exp").Int(), 0), time.Second) - assert.EqualValues(t, `bar`, i.Get("ext.foo").String(), "%s", i) - assert.EqualValues(t, scopes, i.Get("scp").Raw, "%s", i) - return i - } - - waitForRefreshTokenExpiry := func() { - time.Sleep(reg.Config().GetRefreshTokenLifespan(ctx) + time.Second) + require.NotEmpty(t, v.RedirectTo) + http.Redirect(w, r, v.RedirectTo, http.StatusFound) } +} - t.Run("case=checks if request fails when audience does not match", func(t *testing.T) { - testhelpers.NewLoginConsentUI(t, reg.Config(), testhelpers.HTTPServerNoExpectedCallHandler(t), testhelpers.HTTPServerNoExpectedCallHandler(t)) - _, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) - code, _ := getAuthorizeCode(t, conf, nil, oauth2.SetAuthURLParam("audience", "https://not-ory-api/")) - require.Empty(t, code) - }) - - subject := "aeneas-rekkas" - nonce := uuid.New() - t.Run("case=perform authorize code flow with ID token and refresh tokens", func(t *testing.T) { - run := func(t *testing.T, strategy string) { - c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) - testhelpers.NewLoginConsentUI(t, reg.Config(), - acceptLoginHandler(t, c, subject, nil), - acceptConsentHandler(t, c, subject, nil), - ) - - code, _ := getAuthorizeCode(t, conf, nil, oauth2.SetAuthURLParam("nonce", nonce)) - require.NotEmpty(t, code) - token, err := conf.Exchange(context.Background(), code) - iat := time.Now() - require.NoError(t, err) - - assert.Empty(t, token.Extra("c_nonce_draft_00"), "should not be set if not requested") - assert.Empty(t, token.Extra("c_nonce_expires_in_draft_00"), "should not be set if not requested") - introspectAccessToken(t, conf, token, subject) - assertJWTAccessToken(t, strategy, conf, token, subject, iat.Add(reg.Config().GetAccessTokenLifespan(ctx)), `["hydra","offline","openid"]`) - assertIDToken(t, token, conf, subject, nonce, iat.Add(reg.Config().GetIDTokenLifespan(ctx))) - assertRefreshToken(t, token, conf, iat.Add(reg.Config().GetRefreshTokenLifespan(ctx))) - - t.Run("followup=successfully perform refresh token flow", func(t *testing.T) { - require.NotEmpty(t, token.RefreshToken) - token.Expiry = token.Expiry.Add(-time.Hour * 24) - iat = time.Now() - refreshedToken, err := conf.TokenSource(context.Background(), token).Token() - require.NoError(t, err) - - require.NotEqual(t, token.AccessToken, refreshedToken.AccessToken) - require.NotEqual(t, token.RefreshToken, refreshedToken.RefreshToken) - require.NotEqual(t, token.Extra("id_token"), refreshedToken.Extra("id_token")) - introspectAccessToken(t, conf, refreshedToken, subject) - - t.Run("followup=refreshed tokens contain valid tokens", func(t *testing.T) { - assertJWTAccessToken(t, strategy, conf, refreshedToken, subject, iat.Add(reg.Config().GetAccessTokenLifespan(ctx)), `["hydra","offline","openid"]`) - assertIDToken(t, refreshedToken, conf, subject, nonce, iat.Add(reg.Config().GetIDTokenLifespan(ctx))) - assertRefreshToken(t, refreshedToken, conf, iat.Add(reg.Config().GetRefreshTokenLifespan(ctx))) - }) - - t.Run("followup=original access token is no longer valid", func(t *testing.T) { - i := testhelpers.IntrospectToken(t, conf, token.AccessToken, adminTS) - assert.False(t, i.Get("active").Bool(), "%s", i) - }) - - t.Run("followup=original refresh token is no longer valid", func(t *testing.T) { - _, err := conf.TokenSource(context.Background(), token).Token() - assert.Error(t, err) - }) - - t.Run("followup=but fail subsequent refresh because expiry was reached", func(t *testing.T) { - waitForRefreshTokenExpiry() +// TestAuthCodeWithDefaultStrategy runs proper integration tests against in-memory and database connectors, specifically +// we test: +// +// - [x] If the flow - in general - works +// - [x] If `authenticatedAt` is properly managed across the lifecycle +// - [x] The value `authenticatedAt` should be an old time if no user interaction wrt login was required +// - [x] The value `authenticatedAt` should be a recent time if user interaction wrt login was required +// +// - [x] If `requestedAt` is properly managed across the lifecycle +// - [x] The value of `requestedAt` must be the initial request time, not some other time (e.g. when accepting login) +// +// - [x] If `id_token_hint` is handled properly +// - [x] What happens if `id_token_hint` does not match the value from the handled authentication request ("accept login") +func TestAuthCodeWithDefaultStrategy(t *testing.T) { + setupRegistries(t) + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + ctx := context.Background() - // Force golang to refresh token - refreshedToken.Expiry = refreshedToken.Expiry.Add(-time.Hour * 24) - _, err := conf.TokenSource(context.Background(), refreshedToken).Token() - require.Error(t, err) - }) - }) - } + for dbName, reg := range registries { + t.Run("registry="+dbName, func(t *testing.T) { + reg := testhelpers.NewRegistrySQLFromURL(t, reg.Config().DSN(), true, &contextx.Default{}) - t.Run("strategy=jwt", func(t *testing.T) { - reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "jwt") - run(t, "jwt") - }) + require.NoError(t, jwk.EnsureAsymmetricKeypairExists(ctx, reg, string(jose.ES256), x.OpenIDConnectKeyName)) + require.NoError(t, jwk.EnsureAsymmetricKeypairExists(ctx, reg, string(jose.ES256), x.OAuth2JWTKeyName)) - t.Run("strategy=opaque", func(t *testing.T) { reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "opaque") - run(t, "opaque") - }) - }) - - t.Run("case=graceful token rotation", func(t *testing.T) { - run := func(t *testing.T, strategy string) { - reg.Config().MustSet(ctx, config.KeyRefreshTokenRotationGracePeriod, "5s") - t.Cleanup(func() { - reg.Config().MustSet(ctx, config.KeyRefreshTokenRotationGracePeriod, nil) - }) + reg.Config().MustSet(ctx, config.KeyRefreshTokenHook, "") + publicTS, adminTS := testhelpers.NewOAuth2Server(ctx, t, reg) + + publicClient := hydra.NewAPIClient(hydra.NewConfiguration()) + publicClient.GetConfig().Servers = hydra.ServerConfigurations{{URL: publicTS.URL}} + adminClient := hydra.NewAPIClient(hydra.NewConfiguration()) + adminClient.GetConfig().Servers = hydra.ServerConfigurations{{URL: adminTS.URL}} + + assertRefreshToken := func(t *testing.T, token *oauth2.Token, c *oauth2.Config, expectedExp time.Time) { + introspect := testhelpers.IntrospectToken(t, c, token.RefreshToken, adminTS) + actualExp, err := strconv.ParseInt(introspect.Get("exp").String(), 10, 64) + require.NoError(t, err, "%s", introspect) + requirex.EqualTime(t, expectedExp, time.Unix(actualExp, 0), time.Second*3) + } - c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) - testhelpers.NewLoginConsentUI(t, reg.Config(), - acceptLoginHandler(t, c, subject, nil), - acceptConsentHandler(t, c, subject, nil), - ) + assertIDToken := func(t *testing.T, token *oauth2.Token, c *oauth2.Config, expectedSubject, expectedNonce string, expectedExp time.Time) gjson.Result { + idt, ok := token.Extra("id_token").(string) + require.True(t, ok) + assert.NotEmpty(t, idt) - issueTokens := func(t *testing.T) *oauth2.Token { - code, _ := getAuthorizeCode(t, conf, nil, oauth2.SetAuthURLParam("nonce", nonce)) - require.NotEmpty(t, code) - token, err := conf.Exchange(context.Background(), code) - iat := time.Now() + body, err := x.DecodeSegment(strings.Split(idt, ".")[1]) require.NoError(t, err) - introspectAccessToken(t, conf, token, subject) - assertJWTAccessToken(t, strategy, conf, token, subject, iat.Add(reg.Config().GetAccessTokenLifespan(ctx)), `["hydra","offline","openid"]`) - assertIDToken(t, token, conf, subject, nonce, iat.Add(reg.Config().GetIDTokenLifespan(ctx))) - assertRefreshToken(t, token, conf, iat.Add(reg.Config().GetRefreshTokenLifespan(ctx))) - return token + claims := gjson.ParseBytes(body) + assert.True(t, time.Now().After(time.Unix(claims.Get("iat").Int(), 0)), "%s", claims) + assert.True(t, time.Now().After(time.Unix(claims.Get("nbf").Int(), 0)), "%s", claims) + assert.True(t, time.Now().Before(time.Unix(claims.Get("exp").Int(), 0)), "%s", claims) + if !expectedExp.IsZero() { + // 1.5s due to rounding + requirex.EqualTime(t, expectedExp, time.Unix(claims.Get("exp").Int(), 0), 1*time.Second+500*time.Millisecond) + } + assert.NotEmpty(t, claims.Get("jti").String(), "%s", claims) + assert.EqualValues(t, reg.Config().IssuerURL(ctx).String(), claims.Get("iss").String(), "%s", claims) + assert.NotEmpty(t, claims.Get("sid").String(), "%s", claims) + assert.Equal(t, "1", claims.Get("acr").String(), "%s", claims) + require.Len(t, claims.Get("amr").Array(), 1, "%s", claims) + assert.EqualValues(t, "pwd", claims.Get("amr").Array()[0].String(), "%s", claims) + + require.Len(t, claims.Get("aud").Array(), 1, "%s", claims) + assert.EqualValues(t, c.ClientID, claims.Get("aud").Array()[0].String(), "%s", claims) + assert.EqualValues(t, expectedSubject, claims.Get("sub").String(), "%s", claims) + assert.EqualValues(t, expectedNonce, claims.Get("nonce").String(), "%s", claims) + assert.EqualValues(t, `baz`, claims.Get("bar").String(), "%s", claims) + assert.EqualValues(t, `foo@bar.com`, claims.Get("email").String(), "%s", claims) + assert.NotEmpty(t, claims.Get("sid").String(), "%s", claims) + + return claims } - refreshTokens := func(t *testing.T, token *oauth2.Token) *oauth2.Token { - require.NotEmpty(t, token.RefreshToken) - token.Expiry = token.Expiry.Add(-time.Hour * 24) - iat := time.Now() - refreshedToken, err := conf.TokenSource(context.Background(), token).Token() - require.NoError(t, err) - - require.NotEqual(t, token.AccessToken, refreshedToken.AccessToken) - require.NotEqual(t, token.RefreshToken, refreshedToken.RefreshToken) - require.NotEqual(t, token.Extra("id_token"), refreshedToken.Extra("id_token")) - - introspectAccessToken(t, conf, refreshedToken, subject) - assertJWTAccessToken(t, strategy, conf, refreshedToken, subject, iat.Add(reg.Config().GetAccessTokenLifespan(ctx)), `["hydra","offline","openid"]`) - assertIDToken(t, refreshedToken, conf, subject, nonce, iat.Add(reg.Config().GetIDTokenLifespan(ctx))) - assertRefreshToken(t, refreshedToken, conf, iat.Add(reg.Config().GetRefreshTokenLifespan(ctx))) - return refreshedToken + introspectAccessToken := func(t *testing.T, conf *oauth2.Config, token *oauth2.Token, expectedSubject string) gjson.Result { + require.NotEmpty(t, token.AccessToken) + i := testhelpers.IntrospectToken(t, conf, token.AccessToken, adminTS) + assert.True(t, i.Get("active").Bool(), "%s", i) + assert.EqualValues(t, conf.ClientID, i.Get("client_id").String(), "%s", i) + assert.EqualValues(t, expectedSubject, i.Get("sub").String(), "%s", i) + assert.EqualValues(t, `bar`, i.Get("ext.foo").String(), "%s", i) + return i } - t.Run("followup=successfully perform refresh token flow", func(t *testing.T) { - start := time.Now() - - token := issueTokens(t) - var first, second *oauth2.Token - t.Run("followup=first refresh", func(t *testing.T) { - first = refreshTokens(t, token) - }) - - t.Run("followup=second refresh", func(t *testing.T) { - second = refreshTokens(t, token) - }) + assertJWTAccessToken := func(t *testing.T, strat string, conf *oauth2.Config, token *oauth2.Token, expectedSubject string, expectedExp time.Time, scopes string) gjson.Result { + require.NotEmpty(t, token.AccessToken) + parts := strings.Split(token.AccessToken, ".") + if strat != "jwt" { + require.Len(t, parts, 2) + return gjson.Parse("null") + } + require.Len(t, parts, 3) - // Sleep until the grace period is over - time.Sleep(time.Until(start.Add(5*time.Second + time.Millisecond*10))) - t.Run("followup=refresh failure invalidates all tokens", func(t *testing.T) { - _, err := conf.TokenSource(context.Background(), token).Token() - assert.Error(t, err) + body, err := x.DecodeSegment(parts[1]) + require.NoError(t, err) - i := testhelpers.IntrospectToken(t, conf, first.AccessToken, adminTS) - assert.False(t, i.Get("active").Bool(), "%s", i) + i := gjson.ParseBytes(body) + assert.NotEmpty(t, i.Get("jti").String()) + assert.EqualValues(t, conf.ClientID, i.Get("client_id").String(), "%s", i) + assert.EqualValues(t, expectedSubject, i.Get("sub").String(), "%s", i) + assert.EqualValues(t, reg.Config().IssuerURL(ctx).String(), i.Get("iss").String(), "%s", i) + assert.True(t, time.Now().After(time.Unix(i.Get("iat").Int(), 0)), "%s", i) + assert.True(t, time.Now().After(time.Unix(i.Get("nbf").Int(), 0)), "%s", i) + assert.True(t, time.Now().Before(time.Unix(i.Get("exp").Int(), 0)), "%s", i) + requirex.EqualTime(t, expectedExp, time.Unix(i.Get("exp").Int(), 0), time.Second) + assert.EqualValues(t, `bar`, i.Get("ext.foo").String(), "%s", i) + assert.EqualValues(t, scopes, i.Get("scp").Raw, "%s", i) + return i + } - i = testhelpers.IntrospectToken(t, conf, second.AccessToken, adminTS) - assert.False(t, i.Get("active").Bool(), "%s", i) + waitForRefreshTokenExpiry := func() { + time.Sleep(reg.Config().GetRefreshTokenLifespan(ctx) + time.Second) + } - i = testhelpers.IntrospectToken(t, conf, first.RefreshToken, adminTS) - assert.False(t, i.Get("active").Bool(), "%s", i) + subject := "aeneas-rekkas" + nonce := uuid.New() - i = testhelpers.IntrospectToken(t, conf, second.RefreshToken, adminTS) - assert.False(t, i.Get("active").Bool(), "%s", i) - }) + t.Run("case=checks if request fails when audience does not match", func(t *testing.T) { + testhelpers.NewLoginConsentUI(t, reg.Config(), testhelpers.HTTPServerNoExpectedCallHandler(t), testhelpers.HTTPServerNoExpectedCallHandler(t)) + _, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) + code, _ := getAuthorizeCode(t, conf, nil, oauth2.SetAuthURLParam("audience", "https://not-ory-api/")) + require.Empty(t, code) }) - t.Run("followup=successfully perform refresh token flow", func(t *testing.T) { - start := time.Now() - - token := issueTokens(t) - var first, second *oauth2.Token - t.Run("followup=first refresh", func(t *testing.T) { - first = refreshTokens(t, token) - }) - - t.Run("followup=second refresh", func(t *testing.T) { - second = refreshTokens(t, token) - }) + t.Run("case=perform authorize code flow with ID token and refresh tokens", func(t *testing.T) { + run := func(t *testing.T, strategy string) { + c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) + testhelpers.NewLoginConsentUI(t, reg.Config(), + acceptLoginHandler(t, c, adminClient, reg, subject, nil), + acceptConsentHandler(t, c, adminClient, reg, subject, nil), + ) - // Sleep until the grace period is over - time.Sleep(time.Until(start.Add(5*time.Second + time.Millisecond*10))) - t.Run("followup=revoking consent revokes all tokens", func(t *testing.T) { - err := reg.ConsentManager().RevokeSubjectConsentSession(context.Background(), subject) + code, _ := getAuthorizeCode(t, conf, nil, oauth2.SetAuthURLParam("nonce", nonce)) + require.NotEmpty(t, code) + token, err := conf.Exchange(context.Background(), code) + iat := time.Now() require.NoError(t, err) - _, err = conf.TokenSource(context.Background(), token).Token() - assert.Error(t, err) + assert.Empty(t, token.Extra("c_nonce_draft_00"), "should not be set if not requested") + assert.Empty(t, token.Extra("c_nonce_expires_in_draft_00"), "should not be set if not requested") + introspectAccessToken(t, conf, token, subject) + assertJWTAccessToken(t, strategy, conf, token, subject, iat.Add(reg.Config().GetAccessTokenLifespan(ctx)), `["hydra","offline","openid"]`) + assertIDToken(t, token, conf, subject, nonce, iat.Add(reg.Config().GetIDTokenLifespan(ctx))) + assertRefreshToken(t, token, conf, iat.Add(reg.Config().GetRefreshTokenLifespan(ctx))) + + t.Run("followup=successfully perform refresh token flow", func(t *testing.T) { + require.NotEmpty(t, token.RefreshToken) + token.Expiry = token.Expiry.Add(-time.Hour * 24) + iat = time.Now() + refreshedToken, err := conf.TokenSource(context.Background(), token).Token() + require.NoError(t, err) - i := testhelpers.IntrospectToken(t, conf, first.AccessToken, adminTS) - assert.False(t, i.Get("active").Bool(), "%s", i) + require.NotEqual(t, token.AccessToken, refreshedToken.AccessToken) + require.NotEqual(t, token.RefreshToken, refreshedToken.RefreshToken) + require.NotEqual(t, token.Extra("id_token"), refreshedToken.Extra("id_token")) + introspectAccessToken(t, conf, refreshedToken, subject) - i = testhelpers.IntrospectToken(t, conf, second.AccessToken, adminTS) - assert.False(t, i.Get("active").Bool(), "%s", i) + t.Run("followup=refreshed tokens contain valid tokens", func(t *testing.T) { + assertJWTAccessToken(t, strategy, conf, refreshedToken, subject, iat.Add(reg.Config().GetAccessTokenLifespan(ctx)), `["hydra","offline","openid"]`) + assertIDToken(t, refreshedToken, conf, subject, nonce, iat.Add(reg.Config().GetIDTokenLifespan(ctx))) + assertRefreshToken(t, refreshedToken, conf, iat.Add(reg.Config().GetRefreshTokenLifespan(ctx))) + }) - i = testhelpers.IntrospectToken(t, conf, first.RefreshToken, adminTS) - assert.False(t, i.Get("active").Bool(), "%s", i) + t.Run("followup=original access token is no longer valid", func(t *testing.T) { + i := testhelpers.IntrospectToken(t, conf, token.AccessToken, adminTS) + assert.False(t, i.Get("active").Bool(), "%s", i) + }) - i = testhelpers.IntrospectToken(t, conf, second.RefreshToken, adminTS) - assert.False(t, i.Get("active").Bool(), "%s", i) - }) - }) + t.Run("followup=original refresh token is no longer valid", func(t *testing.T) { + _, err := conf.TokenSource(context.Background(), token).Token() + assert.Error(t, err) + }) - t.Run("followup=graceful refresh tokens are all refreshed", func(t *testing.T) { - start := time.Now() - token := issueTokens(t) - var a1Refresh, b1Refresh, a2RefreshA, a2RefreshB, b2RefreshA, b2RefreshB *oauth2.Token - t.Run("followup=first refresh", func(t *testing.T) { - a1Refresh = refreshTokens(t, token) - }) + t.Run("followup=but fail subsequent refresh because expiry was reached", func(t *testing.T) { + waitForRefreshTokenExpiry() - t.Run("followup=second refresh", func(t *testing.T) { - b1Refresh = refreshTokens(t, token) - }) + // Force golang to refresh token + refreshedToken.Expiry = refreshedToken.Expiry.Add(-time.Hour * 24) + _, err := conf.TokenSource(context.Background(), refreshedToken).Token() + require.Error(t, err) + }) + }) + } - t.Run("followup=first refresh from first refresh", func(t *testing.T) { - a2RefreshA = refreshTokens(t, a1Refresh) + t.Run("strategy=jwt", func(t *testing.T) { + reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "jwt") + run(t, "jwt") }) - t.Run("followup=second refresh from first refresh", func(t *testing.T) { - a2RefreshB = refreshTokens(t, a1Refresh) + t.Run("strategy=opaque", func(t *testing.T) { + reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "opaque") + run(t, "opaque") }) + }) - t.Run("followup=first refresh from second refresh", func(t *testing.T) { - b2RefreshA = refreshTokens(t, b1Refresh) - }) + t.Run("case=perform authorize code flow with verifable credentials", func(t *testing.T) { + // Make sure we test against all crypto suites that we advertise. + cfg, _, err := publicClient.OidcAPI.DiscoverOidcConfiguration(ctx).Execute() + require.NoError(t, err) + supportedCryptoSuites := cfg.CredentialsSupportedDraft00[0].CryptographicSuitesSupported + + run := func(t *testing.T, strategy string) { + _, conf := newOAuth2Client( + t, + reg, + testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler), + withScope("openid userinfo_credential_draft_00"), + ) + testhelpers.NewLoginConsentUI(t, reg.Config(), + func(w http.ResponseWriter, r *http.Request) { + acceptBody := hydra.AcceptOAuth2LoginRequest{ + Subject: subject, + Acr: pointerx.Ptr("1"), + Amr: []string{"pwd"}, + Context: map[string]interface{}{"context": "bar"}, + } + v, _, err := adminClient.OAuth2API.AcceptOAuth2LoginRequest(context.Background()). + LoginChallenge(r.URL.Query().Get("login_challenge")). + AcceptOAuth2LoginRequest(acceptBody). + Execute() + require.NoError(t, err) + require.NotEmpty(t, v.RedirectTo) + http.Redirect(w, r, v.RedirectTo, http.StatusFound) + }, + func(w http.ResponseWriter, r *http.Request) { + rr, _, err := adminClient.OAuth2API.GetOAuth2ConsentRequest(context.Background()).ConsentChallenge(r.URL.Query().Get("consent_challenge")).Execute() + require.NoError(t, err) - t.Run("followup=second refresh from second refresh", func(t *testing.T) { - b2RefreshB = refreshTokens(t, b1Refresh) - }) + assert.Equal(t, map[string]interface{}{"context": "bar"}, rr.Context) + v, _, err := adminClient.OAuth2API.AcceptOAuth2ConsentRequest(context.Background()). + ConsentChallenge(r.URL.Query().Get("consent_challenge")). + AcceptOAuth2ConsentRequest(hydra.AcceptOAuth2ConsentRequest{ + GrantScope: []string{"openid", "userinfo_credential_draft_00"}, + GrantAccessTokenAudience: rr.RequestedAccessTokenAudience, + Session: &hydra.AcceptOAuth2ConsentRequestSession{ + AccessToken: map[string]interface{}{"foo": "bar"}, + IdToken: map[string]interface{}{"email": "foo@bar.com", "bar": "baz"}, + }, + }). + Execute() + require.NoError(t, err) + require.NotEmpty(t, v.RedirectTo) + http.Redirect(w, r, v.RedirectTo, http.StatusFound) + }, + ) - // Sleep until the grace period is over - time.Sleep(time.Until(start.Add(5*time.Second + time.Millisecond*10))) - t.Run("followup=refresh failure invalidates all tokens", func(t *testing.T) { - _, err := conf.TokenSource(context.Background(), token).Token() - assert.Error(t, err) + code, _ := getAuthorizeCode(t, conf, nil, + oauth2.SetAuthURLParam("nonce", nonce), + oauth2.SetAuthURLParam("scope", "openid userinfo_credential_draft_00"), + ) + require.NotEmpty(t, code) + token, err := conf.Exchange(context.Background(), code) + require.NoError(t, err) + iat := time.Now() - for k, token := range []*oauth2.Token{ - a1Refresh, b1Refresh, a2RefreshA, a2RefreshB, b2RefreshA, b2RefreshB, - } { - t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { - i := testhelpers.IntrospectToken(t, conf, token.AccessToken, adminTS) - assert.False(t, i.Get("active").Bool(), "%s", i) + vcNonce := token.Extra("c_nonce_draft_00").(string) + assert.NotEmpty(t, vcNonce) + expiry := token.Extra("c_nonce_expires_in_draft_00") + assert.NotEmpty(t, expiry) + assert.NoError(t, reg.Persister().IsNonceValid(ctx, token.AccessToken, vcNonce)) - i = testhelpers.IntrospectToken(t, conf, token.AccessToken, adminTS) - assert.False(t, i.Get("active").Bool(), "%s", i) + t.Run("followup=successfully create a verifiable credential", func(t *testing.T) { + t.Parallel() - i = testhelpers.IntrospectToken(t, conf, token.RefreshToken, adminTS) - assert.False(t, i.Get("active").Bool(), "%s", i) + for _, alg := range supportedCryptoSuites { + alg := alg + t.Run(fmt.Sprintf("alg=%s", alg), func(t *testing.T) { + t.Parallel() + assertCreateVerifiableCredential(t, reg, vcNonce, token, jose.SignatureAlgorithm(alg)) + }) + } + }) - i = testhelpers.IntrospectToken(t, conf, token.RefreshToken, adminTS) - assert.False(t, i.Get("active").Bool(), "%s", i) + t.Run("followup=get new nonce from priming request", func(t *testing.T) { + t.Parallel() + // Assert that we can fetch a verifiable credential with the nonce. + res, err := doPrimingRequest(t, reg, token, &hydraoauth2.CreateVerifiableCredentialRequestBody{ + Format: "jwt_vc_json", + Types: []string{"VerifiableCredential", "UserInfoCredential"}, }) - } - }) - }) - } + assert.NoError(t, err) - t.Run("strategy=jwt", func(t *testing.T) { - reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "jwt") - run(t, "jwt") - }) + t.Run("followup=successfully create a verifiable credential from fresh nonce", func(t *testing.T) { + assertCreateVerifiableCredential(t, reg, res.Nonce, token, jose.ES256) + }) + }) - t.Run("strategy=opaque", func(t *testing.T) { - reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "opaque") - run(t, "opaque") - }) - }) + t.Run("followup=rejects proof signed by another key", func(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + format string + proofType string + proof func() string + }{ + { + name: "proof=mismatching keys", + proof: func() string { + // Create mismatching public and private keys. + pubKey, _, err := josex.NewSigningKey(jose.ES256, 0) + require.NoError(t, err) + _, privKey, err := josex.NewSigningKey(jose.ES256, 0) + require.NoError(t, err) + pubKeyJWK := &jose.JSONWebKey{Key: pubKey, Algorithm: string(jose.ES256)} + return createVCProofJWT(t, pubKeyJWK, privKey, vcNonce) + }, + }, + { + name: "proof=invalid format", + format: "invalid_format", + proof: func() string { + // Create mismatching public and private keys. + pubKey, privKey, err := josex.NewSigningKey(jose.ES256, 0) + require.NoError(t, err) + pubKeyJWK := &jose.JSONWebKey{Key: pubKey, Algorithm: string(jose.ES256)} + return createVCProofJWT(t, pubKeyJWK, privKey, vcNonce) + }, + }, + { + name: "proof=invalid type", + proofType: "invalid", + proof: func() string { + // Create mismatching public and private keys. + pubKey, privKey, err := josex.NewSigningKey(jose.ES256, 0) + require.NoError(t, err) + pubKeyJWK := &jose.JSONWebKey{Key: pubKey, Algorithm: string(jose.ES256)} + return createVCProofJWT(t, pubKeyJWK, privKey, vcNonce) + }, + }, + { + name: "proof=invalid nonce", + proof: func() string { + // Create mismatching public and private keys. + pubKey, privKey, err := josex.NewSigningKey(jose.ES256, 0) + require.NoError(t, err) + pubKeyJWK := &jose.JSONWebKey{Key: pubKey, Algorithm: string(jose.ES256)} + return createVCProofJWT(t, pubKeyJWK, privKey, "invalid nonce") + }, + }, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := createVerifiableCredential(t, reg, token, &hydraoauth2.CreateVerifiableCredentialRequestBody{ + Format: stringsx.Coalesce(tc.format, "jwt_vc_json"), + Types: []string{"VerifiableCredential", "UserInfoCredential"}, + Proof: &hydraoauth2.VerifiableCredentialProof{ + ProofType: stringsx.Coalesce(tc.proofType, "jwt"), + JWT: tc.proof(), + }, + }) + require.Error(t, err) + assert.Equal(t, "invalid_request", err.Error()) + }) + } - t.Run("case=perform authorize code flow with verifable credentials", func(t *testing.T) { - // Make sure we test against all crypto suites that we advertise. - cfg, _, err := publicClient.OidcAPI.DiscoverOidcConfiguration(ctx).Execute() - require.NoError(t, err) - supportedCryptoSuites := cfg.CredentialsSupportedDraft00[0].CryptographicSuitesSupported - - run := func(t *testing.T, strategy string) { - _, conf := newOAuth2Client( - t, - reg, - testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler), - withScope("openid userinfo_credential_draft_00"), - ) - testhelpers.NewLoginConsentUI(t, reg.Config(), - func(w http.ResponseWriter, r *http.Request) { - acceptBody := hydra.AcceptOAuth2LoginRequest{ - Subject: subject, - Acr: pointerx.Ptr("1"), - Amr: []string{"pwd"}, - Context: map[string]interface{}{"context": "bar"}, - } - v, _, err := adminClient.OAuth2API.AcceptOAuth2LoginRequest(context.Background()). - LoginChallenge(r.URL.Query().Get("login_challenge")). - AcceptOAuth2LoginRequest(acceptBody). - Execute() - require.NoError(t, err) - require.NotEmpty(t, v.RedirectTo) - http.Redirect(w, r, v.RedirectTo, http.StatusFound) - }, - func(w http.ResponseWriter, r *http.Request) { - rr, _, err := adminClient.OAuth2API.GetOAuth2ConsentRequest(context.Background()).ConsentChallenge(r.URL.Query().Get("consent_challenge")).Execute() - require.NoError(t, err) + }) - assert.Equal(t, map[string]interface{}{"context": "bar"}, rr.Context) - v, _, err := adminClient.OAuth2API.AcceptOAuth2ConsentRequest(context.Background()). - ConsentChallenge(r.URL.Query().Get("consent_challenge")). - AcceptOAuth2ConsentRequest(hydra.AcceptOAuth2ConsentRequest{ - GrantScope: []string{"openid", "userinfo_credential_draft_00"}, - GrantAccessTokenAudience: rr.RequestedAccessTokenAudience, - Session: &hydra.AcceptOAuth2ConsentRequestSession{ - AccessToken: map[string]interface{}{"foo": "bar"}, - IdToken: map[string]interface{}{"email": "foo@bar.com", "bar": "baz"}, - }, - }). - Execute() - require.NoError(t, err) - require.NotEmpty(t, v.RedirectTo) - http.Redirect(w, r, v.RedirectTo, http.StatusFound) - }, - ) - - code, _ := getAuthorizeCode(t, conf, nil, - oauth2.SetAuthURLParam("nonce", nonce), - oauth2.SetAuthURLParam("scope", "openid userinfo_credential_draft_00"), - ) - require.NotEmpty(t, code) - token, err := conf.Exchange(context.Background(), code) - require.NoError(t, err) - iat := time.Now() - - vcNonce := token.Extra("c_nonce_draft_00").(string) - assert.NotEmpty(t, vcNonce) - expiry := token.Extra("c_nonce_expires_in_draft_00") - assert.NotEmpty(t, expiry) - assert.NoError(t, reg.Persister().IsNonceValid(ctx, token.AccessToken, vcNonce)) - - t.Run("followup=successfully create a verifiable credential", func(t *testing.T) { - t.Parallel() - - for _, alg := range supportedCryptoSuites { - alg := alg - t.Run(fmt.Sprintf("alg=%s", alg), func(t *testing.T) { - t.Parallel() - assertCreateVerifiableCredential(t, reg, vcNonce, token, jose.SignatureAlgorithm(alg)) + t.Run("followup=access token and id token are valid", func(t *testing.T) { + assertJWTAccessToken(t, strategy, conf, token, subject, iat.Add(reg.Config().GetAccessTokenLifespan(ctx)), `["openid","userinfo_credential_draft_00"]`) + assertIDToken(t, token, conf, subject, nonce, iat.Add(reg.Config().GetIDTokenLifespan(ctx))) }) } - }) - t.Run("followup=get new nonce from priming request", func(t *testing.T) { - t.Parallel() - // Assert that we can fetch a verifiable credential with the nonce. - res, err := doPrimingRequest(t, reg, token, &hydraoauth2.CreateVerifiableCredentialRequestBody{ - Format: "jwt_vc_json", - Types: []string{"VerifiableCredential", "UserInfoCredential"}, + t.Run("strategy=jwt", func(t *testing.T) { + reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "jwt") + run(t, "jwt") }) - assert.NoError(t, err) - t.Run("followup=successfully create a verifiable credential from fresh nonce", func(t *testing.T) { - assertCreateVerifiableCredential(t, reg, res.Nonce, token, jose.ES256) + t.Run("strategy=opaque", func(t *testing.T) { + reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "opaque") + run(t, "opaque") }) }) - t.Run("followup=rejects proof signed by another key", func(t *testing.T) { - t.Parallel() - for _, tc := range []struct { - name string - format string - proofType string - proof func() string - }{ - { - name: "proof=mismatching keys", - proof: func() string { - // Create mismatching public and private keys. - pubKey, _, err := josex.NewSigningKey(jose.ES256, 0) - require.NoError(t, err) - _, privKey, err := josex.NewSigningKey(jose.ES256, 0) - require.NoError(t, err) - pubKeyJWK := &jose.JSONWebKey{Key: pubKey, Algorithm: string(jose.ES256)} - return createVCProofJWT(t, pubKeyJWK, privKey, vcNonce) - }, + t.Run("suite=invalid query params", func(t *testing.T) { + c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) + otherClient, _ := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) + testhelpers.NewLoginConsentUI(t, reg.Config(), + acceptLoginHandler(t, c, adminClient, reg, subject, nil), + acceptConsentHandler(t, c, adminClient, reg, subject, nil), + ) + + withWrongClientAfterLogin := &http.Client{ + Jar: testhelpers.NewEmptyCookieJar(t), + CheckRedirect: func(req *http.Request, _ []*http.Request) error { + if req.URL.Path != "/oauth2/auth" { + return nil + } + q := req.URL.Query() + if !q.Has("login_verifier") { + return nil + } + q.Set("client_id", otherClient.GetID()) + req.URL.RawQuery = q.Encode() + return nil }, - { - name: "proof=invalid format", - format: "invalid_format", - proof: func() string { - // Create mismatching public and private keys. - pubKey, privKey, err := josex.NewSigningKey(jose.ES256, 0) - require.NoError(t, err) - pubKeyJWK := &jose.JSONWebKey{Key: pubKey, Algorithm: string(jose.ES256)} - return createVCProofJWT(t, pubKeyJWK, privKey, vcNonce) - }, + } + withWrongClientAfterConsent := &http.Client{ + Jar: testhelpers.NewEmptyCookieJar(t), + CheckRedirect: func(req *http.Request, _ []*http.Request) error { + if req.URL.Path != "/oauth2/auth" { + return nil + } + q := req.URL.Query() + if !q.Has("consent_verifier") { + return nil + } + q.Set("client_id", otherClient.GetID()) + req.URL.RawQuery = q.Encode() + return nil }, - { - name: "proof=invalid type", - proofType: "invalid", - proof: func() string { - // Create mismatching public and private keys. - pubKey, privKey, err := josex.NewSigningKey(jose.ES256, 0) - require.NoError(t, err) - pubKeyJWK := &jose.JSONWebKey{Key: pubKey, Algorithm: string(jose.ES256)} - return createVCProofJWT(t, pubKeyJWK, privKey, vcNonce) - }, + } + + withWrongScopeAfterLogin := &http.Client{ + Jar: testhelpers.NewEmptyCookieJar(t), + CheckRedirect: func(req *http.Request, _ []*http.Request) error { + if req.URL.Path != "/oauth2/auth" { + return nil + } + q := req.URL.Query() + if !q.Has("login_verifier") { + return nil + } + q.Set("scope", "invalid scope") + req.URL.RawQuery = q.Encode() + return nil }, - { - name: "proof=invalid nonce", - proof: func() string { - // Create mismatching public and private keys. - pubKey, privKey, err := josex.NewSigningKey(jose.ES256, 0) - require.NoError(t, err) - pubKeyJWK := &jose.JSONWebKey{Key: pubKey, Algorithm: string(jose.ES256)} - return createVCProofJWT(t, pubKeyJWK, privKey, "invalid nonce") - }, + } + + withWrongScopeAfterConsent := &http.Client{ + Jar: testhelpers.NewEmptyCookieJar(t), + CheckRedirect: func(req *http.Request, _ []*http.Request) error { + if req.URL.Path != "/oauth2/auth" { + return nil + } + q := req.URL.Query() + if !q.Has("consent_verifier") { + return nil + } + q.Set("scope", "invalid scope") + req.URL.RawQuery = q.Encode() + return nil }, - } { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - _, res := createVerifiableCredential(t, reg, token, &hydraoauth2.CreateVerifiableCredentialRequestBody{ - Format: stringsx.Coalesce(tc.format, "jwt_vc_json"), - Types: []string{"VerifiableCredential", "UserInfoCredential"}, - Proof: &hydraoauth2.VerifiableCredentialProof{ - ProofType: stringsx.Coalesce(tc.proofType, "jwt"), - JWT: tc.proof(), - }, - }) + } + for _, tc := range []struct { + name string + client *http.Client + expectedResponse string + }{{ + name: "fails with wrong client ID after login", + client: withWrongClientAfterLogin, + expectedResponse: "invalid_client", + }, { + name: "fails with wrong client ID after consent", + client: withWrongClientAfterConsent, + expectedResponse: "invalid_client", + }, { + name: "fails with wrong scopes after login", + client: withWrongScopeAfterLogin, + expectedResponse: "invalid_scope", + }, { + name: "fails with wrong scopes after consent", + client: withWrongScopeAfterConsent, + expectedResponse: "invalid_scope", + }} { + t.Run("case="+tc.name, func(t *testing.T) { + state := uuid.New() + resp, err := tc.client.Get(conf.AuthCodeURL(state)) require.NoError(t, err) - require.NotNil(t, res) - assert.Equal(t, "invalid_request", res.Error()) + assert.Equal(t, tc.expectedResponse, resp.Request.URL.Query().Get("error"), "%s", resp.Request.URL.RawQuery) + resp.Body.Close() }) } - }) - t.Run("followup=access token and id token are valid", func(t *testing.T) { - assertJWTAccessToken(t, strategy, conf, token, subject, iat.Add(reg.Config().GetAccessTokenLifespan(ctx)), `["openid","userinfo_credential_draft_00"]`) - assertIDToken(t, token, conf, subject, nonce, iat.Add(reg.Config().GetIDTokenLifespan(ctx))) + t.Run("case=checks if request fails when subject is empty", func(t *testing.T) { + testhelpers.NewLoginConsentUI(t, reg.Config(), func(w http.ResponseWriter, r *http.Request) { + _, res, err := adminClient.OAuth2API.AcceptOAuth2LoginRequest(ctx). + LoginChallenge(r.URL.Query().Get("login_challenge")). + AcceptOAuth2LoginRequest(hydra.AcceptOAuth2LoginRequest{Subject: "", Remember: pointerx.Ptr(true)}).Execute() + require.Error(t, err) // expects 400 + body := string(ioutilx.MustReadAll(res.Body)) + assert.Contains(t, body, "Field 'subject' must not be empty", "%s", body) + }, testhelpers.HTTPServerNoExpectedCallHandler(t)) + _, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) + + _, err := testhelpers.NewEmptyJarClient(t).Get(conf.AuthCodeURL(uuid.New())) + require.NoError(t, err) }) - } - t.Run("strategy=jwt", func(t *testing.T) { - reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "jwt") - run(t, "jwt") - }) + t.Run("case=perform flow with prompt=registration", func(t *testing.T) { + c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) - t.Run("strategy=opaque", func(t *testing.T) { - reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "opaque") - run(t, "opaque") - }) - }) + regUI := httptest.NewServer(acceptLoginHandler(t, c, adminClient, reg, subject, nil)) + t.Cleanup(regUI.Close) + reg.Config().MustSet(ctx, config.KeyRegistrationURL, regUI.URL) - t.Run("suite=invalid query params", func(t *testing.T) { - c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) - otherClient, _ := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) - testhelpers.NewLoginConsentUI(t, reg.Config(), - acceptLoginHandler(t, c, subject, nil), - acceptConsentHandler(t, c, subject, nil), - ) - - withWrongClientAfterLogin := &http.Client{ - Jar: testhelpers.NewEmptyCookieJar(t), - CheckRedirect: func(req *http.Request, _ []*http.Request) error { - if req.URL.Path != "/oauth2/auth" { - return nil - } - q := req.URL.Query() - if !q.Has("login_verifier") { - return nil - } - q.Set("client_id", otherClient.GetID()) - req.URL.RawQuery = q.Encode() - return nil - }, - } - withWrongClientAfterConsent := &http.Client{ - Jar: testhelpers.NewEmptyCookieJar(t), - CheckRedirect: func(req *http.Request, _ []*http.Request) error { - if req.URL.Path != "/oauth2/auth" { - return nil - } - q := req.URL.Query() - if !q.Has("consent_verifier") { - return nil - } - q.Set("client_id", otherClient.GetID()) - req.URL.RawQuery = q.Encode() - return nil - }, - } + testhelpers.NewLoginConsentUI(t, reg.Config(), + nil, + acceptConsentHandler(t, c, adminClient, reg, subject, nil)) - withWrongScopeAfterLogin := &http.Client{ - Jar: testhelpers.NewEmptyCookieJar(t), - CheckRedirect: func(req *http.Request, _ []*http.Request) error { - if req.URL.Path != "/oauth2/auth" { - return nil - } - q := req.URL.Query() - if !q.Has("login_verifier") { - return nil - } - q.Set("scope", "invalid scope") - req.URL.RawQuery = q.Encode() - return nil - }, - } + code, _ := getAuthorizeCode(t, conf, nil, + oauth2.SetAuthURLParam("prompt", "registration"), + oauth2.SetAuthURLParam("nonce", nonce)) + require.NotEmpty(t, code) - withWrongScopeAfterConsent := &http.Client{ - Jar: testhelpers.NewEmptyCookieJar(t), - CheckRedirect: func(req *http.Request, _ []*http.Request) error { - if req.URL.Path != "/oauth2/auth" { - return nil - } - q := req.URL.Query() - if !q.Has("consent_verifier") { - return nil - } - q.Set("scope", "invalid scope") - req.URL.RawQuery = q.Encode() - return nil - }, - } - for _, tc := range []struct { - name string - client *http.Client - expectedResponse string - }{{ - name: "fails with wrong client ID after login", - client: withWrongClientAfterLogin, - expectedResponse: "invalid_client", - }, { - name: "fails with wrong client ID after consent", - client: withWrongClientAfterConsent, - expectedResponse: "invalid_client", - }, { - name: "fails with wrong scopes after login", - client: withWrongScopeAfterLogin, - expectedResponse: "invalid_scope", - }, { - name: "fails with wrong scopes after consent", - client: withWrongScopeAfterConsent, - expectedResponse: "invalid_scope", - }} { - t.Run("case="+tc.name, func(t *testing.T) { - state := uuid.New() - resp, err := tc.client.Get(conf.AuthCodeURL(state)) + token, err := conf.Exchange(context.Background(), code) require.NoError(t, err) - assert.Equal(t, tc.expectedResponse, resp.Request.URL.Query().Get("error"), "%s", resp.Request.URL.RawQuery) - resp.Body.Close() + + assertIDToken(t, token, conf, subject, nonce, time.Now().Add(reg.Config().GetIDTokenLifespan(ctx))) }) - } - }) - t.Run("case=checks if request fails when subject is empty", func(t *testing.T) { - testhelpers.NewLoginConsentUI(t, reg.Config(), func(w http.ResponseWriter, r *http.Request) { - _, res, err := adminClient.OAuth2API.AcceptOAuth2LoginRequest(ctx). - LoginChallenge(r.URL.Query().Get("login_challenge")). - AcceptOAuth2LoginRequest(hydra.AcceptOAuth2LoginRequest{Subject: "", Remember: pointerx.Ptr(true)}).Execute() - require.Error(t, err) // expects 400 - body := string(ioutilx.MustReadAll(res.Body)) - assert.Contains(t, body, "Field 'subject' must not be empty", "%s", body) - }, testhelpers.HTTPServerNoExpectedCallHandler(t)) - _, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) - - _, err := testhelpers.NewEmptyJarClient(t).Get(conf.AuthCodeURL(uuid.New())) - require.NoError(t, err) - }) + t.Run("case=perform flow with audience", func(t *testing.T) { + expectAud := "https://api.ory.sh/" + c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) + testhelpers.NewLoginConsentUI(t, reg.Config(), + acceptLoginHandler(t, c, adminClient, reg, subject, func(r *hydra.OAuth2LoginRequest) *hydra.AcceptOAuth2LoginRequest { + assert.False(t, r.Skip) + assert.EqualValues(t, []string{expectAud}, r.RequestedAccessTokenAudience) + return nil + }), + acceptConsentHandler(t, c, adminClient, reg, subject, func(r *hydra.OAuth2ConsentRequest) { + assert.False(t, *r.Skip) + assert.EqualValues(t, []string{expectAud}, r.RequestedAccessTokenAudience) + })) - t.Run("case=perform flow with prompt=registration", func(t *testing.T) { - c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) + code, _ := getAuthorizeCode(t, conf, nil, + oauth2.SetAuthURLParam("audience", "https://api.ory.sh/"), + oauth2.SetAuthURLParam("nonce", nonce)) + require.NotEmpty(t, code) - regUI := httptest.NewServer(acceptLoginHandler(t, c, subject, nil)) - t.Cleanup(regUI.Close) - reg.Config().MustSet(ctx, config.KeyRegistrationURL, regUI.URL) + token, err := conf.Exchange(context.Background(), code) + require.NoError(t, err) - testhelpers.NewLoginConsentUI(t, reg.Config(), - nil, - acceptConsentHandler(t, c, subject, nil)) + claims := introspectAccessToken(t, conf, token, subject) + aud := claims.Get("aud").Array() + require.Len(t, aud, 1) + assert.EqualValues(t, aud[0].String(), expectAud) - code, _ := getAuthorizeCode(t, conf, nil, - oauth2.SetAuthURLParam("prompt", "registration"), - oauth2.SetAuthURLParam("nonce", nonce)) - require.NotEmpty(t, code) + assertIDToken(t, token, conf, subject, nonce, time.Now().Add(reg.Config().GetIDTokenLifespan(ctx))) + }) - token, err := conf.Exchange(context.Background(), code) - require.NoError(t, err) + t.Run("case=respects client token lifespan configuration", func(t *testing.T) { + run := func(t *testing.T, strategy string, c *client.Client, conf *oauth2.Config, expectedLifespans client.Lifespans) { + testhelpers.NewLoginConsentUI(t, reg.Config(), + acceptLoginHandler(t, c, adminClient, reg, subject, nil), + acceptConsentHandler(t, c, adminClient, reg, subject, nil), + ) - assertIDToken(t, token, conf, subject, nonce, time.Now().Add(reg.Config().GetIDTokenLifespan(ctx))) - }) + code, _ := getAuthorizeCode(t, conf, nil, oauth2.SetAuthURLParam("nonce", nonce)) + require.NotEmpty(t, code) + token, err := conf.Exchange(context.Background(), code) + iat := time.Now() + require.NoError(t, err) - t.Run("case=perform flow with audience", func(t *testing.T) { - expectAud := "https://api.ory.sh/" - c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) - testhelpers.NewLoginConsentUI(t, reg.Config(), - acceptLoginHandler(t, c, subject, func(r *hydra.OAuth2LoginRequest) *hydra.AcceptOAuth2LoginRequest { - assert.False(t, r.Skip) - assert.EqualValues(t, []string{expectAud}, r.RequestedAccessTokenAudience) - return nil - }), - acceptConsentHandler(t, c, subject, func(r *hydra.OAuth2ConsentRequest) { - assert.False(t, *r.Skip) - assert.EqualValues(t, []string{expectAud}, r.RequestedAccessTokenAudience) - })) + body := introspectAccessToken(t, conf, token, subject) + requirex.EqualTime(t, iat.Add(expectedLifespans.AuthorizationCodeGrantAccessTokenLifespan.Duration), time.Unix(body.Get("exp").Int(), 0), time.Second) - code, _ := getAuthorizeCode(t, conf, nil, - oauth2.SetAuthURLParam("audience", "https://api.ory.sh/"), - oauth2.SetAuthURLParam("nonce", nonce)) - require.NotEmpty(t, code) + assertJWTAccessToken(t, strategy, conf, token, subject, iat.Add(expectedLifespans.AuthorizationCodeGrantAccessTokenLifespan.Duration), `["hydra","offline","openid"]`) + assertIDToken(t, token, conf, subject, nonce, iat.Add(expectedLifespans.AuthorizationCodeGrantIDTokenLifespan.Duration)) + assertRefreshToken(t, token, conf, iat.Add(expectedLifespans.AuthorizationCodeGrantRefreshTokenLifespan.Duration)) - token, err := conf.Exchange(context.Background(), code) - require.NoError(t, err) + t.Run("followup=successfully perform refresh token flow", func(t *testing.T) { + require.NotEmpty(t, token.RefreshToken) + token.Expiry = token.Expiry.Add(-time.Hour * 24) + refreshedToken, err := conf.TokenSource(context.Background(), token).Token() + iat = time.Now() + require.NoError(t, err) + assertRefreshToken(t, refreshedToken, conf, iat.Add(expectedLifespans.RefreshTokenGrantRefreshTokenLifespan.Duration)) + assertJWTAccessToken(t, strategy, conf, refreshedToken, subject, iat.Add(expectedLifespans.RefreshTokenGrantAccessTokenLifespan.Duration), `["hydra","offline","openid"]`) + assertIDToken(t, refreshedToken, conf, subject, nonce, iat.Add(expectedLifespans.RefreshTokenGrantIDTokenLifespan.Duration)) - claims := introspectAccessToken(t, conf, token, subject) - aud := claims.Get("aud").Array() - require.Len(t, aud, 1) - assert.EqualValues(t, aud[0].String(), expectAud) + require.NotEqual(t, token.AccessToken, refreshedToken.AccessToken) + require.NotEqual(t, token.RefreshToken, refreshedToken.RefreshToken) + require.NotEqual(t, token.Extra("id_token"), refreshedToken.Extra("id_token")) - assertIDToken(t, token, conf, subject, nonce, time.Now().Add(reg.Config().GetIDTokenLifespan(ctx))) - }) + body := introspectAccessToken(t, conf, refreshedToken, subject) + requirex.EqualTime(t, iat.Add(expectedLifespans.RefreshTokenGrantAccessTokenLifespan.Duration), time.Unix(body.Get("exp").Int(), 0), time.Second) - t.Run("case=respects client token lifespan configuration", func(t *testing.T) { - run := func(t *testing.T, strategy string, c *client.Client, conf *oauth2.Config, expectedLifespans client.Lifespans) { - testhelpers.NewLoginConsentUI(t, reg.Config(), - acceptLoginHandler(t, c, subject, nil), - acceptConsentHandler(t, c, subject, nil), - ) - - code, _ := getAuthorizeCode(t, conf, nil, oauth2.SetAuthURLParam("nonce", nonce)) - require.NotEmpty(t, code) - token, err := conf.Exchange(context.Background(), code) - iat := time.Now() - require.NoError(t, err) - - body := introspectAccessToken(t, conf, token, subject) - requirex.EqualTime(t, iat.Add(expectedLifespans.AuthorizationCodeGrantAccessTokenLifespan.Duration), time.Unix(body.Get("exp").Int(), 0), time.Second) - - assertJWTAccessToken(t, strategy, conf, token, subject, iat.Add(expectedLifespans.AuthorizationCodeGrantAccessTokenLifespan.Duration), `["hydra","offline","openid"]`) - assertIDToken(t, token, conf, subject, nonce, iat.Add(expectedLifespans.AuthorizationCodeGrantIDTokenLifespan.Duration)) - assertRefreshToken(t, token, conf, iat.Add(expectedLifespans.AuthorizationCodeGrantRefreshTokenLifespan.Duration)) - - t.Run("followup=successfully perform refresh token flow", func(t *testing.T) { - require.NotEmpty(t, token.RefreshToken) - token.Expiry = token.Expiry.Add(-time.Hour * 24) - refreshedToken, err := conf.TokenSource(context.Background(), token).Token() - iat = time.Now() - require.NoError(t, err) - assertRefreshToken(t, refreshedToken, conf, iat.Add(expectedLifespans.RefreshTokenGrantRefreshTokenLifespan.Duration)) - assertJWTAccessToken(t, strategy, conf, refreshedToken, subject, iat.Add(expectedLifespans.RefreshTokenGrantAccessTokenLifespan.Duration), `["hydra","offline","openid"]`) - assertIDToken(t, refreshedToken, conf, subject, nonce, iat.Add(expectedLifespans.RefreshTokenGrantIDTokenLifespan.Duration)) + t.Run("followup=original access token is no longer valid", func(t *testing.T) { + i := testhelpers.IntrospectToken(t, conf, token.AccessToken, adminTS) + assert.False(t, i.Get("active").Bool(), "%s", i) + }) - require.NotEqual(t, token.AccessToken, refreshedToken.AccessToken) - require.NotEqual(t, token.RefreshToken, refreshedToken.RefreshToken) - require.NotEqual(t, token.Extra("id_token"), refreshedToken.Extra("id_token")) + t.Run("followup=original refresh token is no longer valid", func(t *testing.T) { + _, err := conf.TokenSource(context.Background(), token).Token() + assert.Error(t, err) + }) + }) + } - body := introspectAccessToken(t, conf, refreshedToken, subject) - requirex.EqualTime(t, iat.Add(expectedLifespans.RefreshTokenGrantAccessTokenLifespan.Duration), time.Unix(body.Get("exp").Int(), 0), time.Second) + t.Run("case=custom-lifespans-active-jwt", func(t *testing.T) { + c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) + ls := testhelpers.TestLifespans + ls.AuthorizationCodeGrantAccessTokenLifespan = x.NullDuration{Valid: true, Duration: 6 * time.Second} + testhelpers.UpdateClientTokenLifespans( + t, + &oauth2.Config{ClientID: c.GetID(), ClientSecret: conf.ClientSecret}, + c.GetID(), + ls, adminTS, + ) + reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "jwt") + run(t, "jwt", c, conf, ls) + }) - t.Run("followup=original access token is no longer valid", func(t *testing.T) { - i := testhelpers.IntrospectToken(t, conf, token.AccessToken, adminTS) - assert.False(t, i.Get("active").Bool(), "%s", i) + t.Run("case=custom-lifespans-active-opaque", func(t *testing.T) { + c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) + ls := testhelpers.TestLifespans + ls.AuthorizationCodeGrantAccessTokenLifespan = x.NullDuration{Valid: true, Duration: 6 * time.Second} + testhelpers.UpdateClientTokenLifespans( + t, + &oauth2.Config{ClientID: c.GetID(), ClientSecret: conf.ClientSecret}, + c.GetID(), + ls, adminTS, + ) + reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "opaque") + run(t, "opaque", c, conf, ls) }) - t.Run("followup=original refresh token is no longer valid", func(t *testing.T) { - _, err := conf.TokenSource(context.Background(), token).Token() - assert.Error(t, err) + t.Run("case=custom-lifespans-unset", func(t *testing.T) { + c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) + testhelpers.UpdateClientTokenLifespans(t, &oauth2.Config{ClientID: c.GetID(), ClientSecret: conf.ClientSecret}, c.GetID(), testhelpers.TestLifespans, adminTS) + testhelpers.UpdateClientTokenLifespans(t, &oauth2.Config{ClientID: c.GetID(), ClientSecret: conf.ClientSecret}, c.GetID(), client.Lifespans{}, adminTS) + reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "opaque") + + //goland:noinspection GoDeprecation + expectedLifespans := client.Lifespans{ + AuthorizationCodeGrantAccessTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetAccessTokenLifespan(ctx)}, + AuthorizationCodeGrantIDTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetIDTokenLifespan(ctx)}, + AuthorizationCodeGrantRefreshTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetRefreshTokenLifespan(ctx)}, + ClientCredentialsGrantAccessTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetAccessTokenLifespan(ctx)}, + ImplicitGrantAccessTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetAccessTokenLifespan(ctx)}, + ImplicitGrantIDTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetIDTokenLifespan(ctx)}, + JwtBearerGrantAccessTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetAccessTokenLifespan(ctx)}, + PasswordGrantAccessTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetAccessTokenLifespan(ctx)}, + PasswordGrantRefreshTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetRefreshTokenLifespan(ctx)}, + RefreshTokenGrantIDTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetIDTokenLifespan(ctx)}, + RefreshTokenGrantAccessTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetAccessTokenLifespan(ctx)}, + RefreshTokenGrantRefreshTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetRefreshTokenLifespan(ctx)}, + } + run(t, "opaque", c, conf, expectedLifespans) }) }) - } - t.Run("case=custom-lifespans-active-jwt", func(t *testing.T) { - c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) - ls := testhelpers.TestLifespans - ls.AuthorizationCodeGrantAccessTokenLifespan = x.NullDuration{Valid: true, Duration: 6 * time.Second} - testhelpers.UpdateClientTokenLifespans( - t, - &oauth2.Config{ClientID: c.GetID(), ClientSecret: conf.ClientSecret}, - c.GetID(), - ls, adminTS, - ) - reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "jwt") - run(t, "jwt", c, conf, ls) - }) + t.Run("case=use remember feature and prompt=none", func(t *testing.T) { + c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) + testhelpers.NewLoginConsentUI(t, reg.Config(), + acceptLoginHandler(t, c, adminClient, reg, subject, nil), + acceptConsentHandler(t, c, adminClient, reg, subject, nil), + ) - t.Run("case=custom-lifespans-active-opaque", func(t *testing.T) { - c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) - ls := testhelpers.TestLifespans - ls.AuthorizationCodeGrantAccessTokenLifespan = x.NullDuration{Valid: true, Duration: 6 * time.Second} - testhelpers.UpdateClientTokenLifespans( - t, - &oauth2.Config{ClientID: c.GetID(), ClientSecret: conf.ClientSecret}, - c.GetID(), - ls, adminTS, - ) - reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "opaque") - run(t, "opaque", c, conf, ls) - }) + oc := testhelpers.NewEmptyJarClient(t) + code, _ := getAuthorizeCode(t, conf, oc, + oauth2.SetAuthURLParam("nonce", nonce), + oauth2.SetAuthURLParam("prompt", "login consent"), + oauth2.SetAuthURLParam("max_age", "1"), + ) + require.NotEmpty(t, code) + token, err := conf.Exchange(context.Background(), code) + require.NoError(t, err) + introspectAccessToken(t, conf, token, subject) - t.Run("case=custom-lifespans-unset", func(t *testing.T) { - c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) - testhelpers.UpdateClientTokenLifespans(t, &oauth2.Config{ClientID: c.GetID(), ClientSecret: conf.ClientSecret}, c.GetID(), testhelpers.TestLifespans, adminTS) - testhelpers.UpdateClientTokenLifespans(t, &oauth2.Config{ClientID: c.GetID(), ClientSecret: conf.ClientSecret}, c.GetID(), client.Lifespans{}, adminTS) - reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "opaque") + // Reset UI to check for skip values + testhelpers.NewLoginConsentUI(t, reg.Config(), + acceptLoginHandler(t, c, adminClient, reg, subject, func(r *hydra.OAuth2LoginRequest) *hydra.AcceptOAuth2LoginRequest { + require.True(t, r.Skip) + require.EqualValues(t, subject, r.Subject) + return nil + }), + acceptConsentHandler(t, c, adminClient, reg, subject, func(r *hydra.OAuth2ConsentRequest) { + require.True(t, *r.Skip) + require.EqualValues(t, subject, *r.Subject) + }), + ) - //goland:noinspection GoDeprecation - expectedLifespans := client.Lifespans{ - AuthorizationCodeGrantAccessTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetAccessTokenLifespan(ctx)}, - AuthorizationCodeGrantIDTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetIDTokenLifespan(ctx)}, - AuthorizationCodeGrantRefreshTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetRefreshTokenLifespan(ctx)}, - ClientCredentialsGrantAccessTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetAccessTokenLifespan(ctx)}, - ImplicitGrantAccessTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetAccessTokenLifespan(ctx)}, - ImplicitGrantIDTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetIDTokenLifespan(ctx)}, - JwtBearerGrantAccessTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetAccessTokenLifespan(ctx)}, - PasswordGrantAccessTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetAccessTokenLifespan(ctx)}, - PasswordGrantRefreshTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetRefreshTokenLifespan(ctx)}, - RefreshTokenGrantIDTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetIDTokenLifespan(ctx)}, - RefreshTokenGrantAccessTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetAccessTokenLifespan(ctx)}, - RefreshTokenGrantRefreshTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetRefreshTokenLifespan(ctx)}, - } - run(t, "opaque", c, conf, expectedLifespans) - }) - }) + t.Run("followup=checks if authenticatedAt/requestedAt is properly forwarded across the lifecycle by checking if prompt=none works", func(t *testing.T) { + // In order to check if authenticatedAt/requestedAt works, we'll sleep first in order to ensure that authenticatedAt is in the past + // if handled correctly. + time.Sleep(time.Second + time.Nanosecond) - t.Run("case=use remember feature and prompt=none", func(t *testing.T) { - c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) - testhelpers.NewLoginConsentUI(t, reg.Config(), - acceptLoginHandler(t, c, subject, nil), - acceptConsentHandler(t, c, subject, nil), - ) - - oc := testhelpers.NewEmptyJarClient(t) - code, _ := getAuthorizeCode(t, conf, oc, - oauth2.SetAuthURLParam("nonce", nonce), - oauth2.SetAuthURLParam("prompt", "login consent"), - oauth2.SetAuthURLParam("max_age", "1"), - ) - require.NotEmpty(t, code) - token, err := conf.Exchange(context.Background(), code) - require.NoError(t, err) - introspectAccessToken(t, conf, token, subject) - - // Reset UI to check for skip values - testhelpers.NewLoginConsentUI(t, reg.Config(), - acceptLoginHandler(t, c, subject, func(r *hydra.OAuth2LoginRequest) *hydra.AcceptOAuth2LoginRequest { - require.True(t, r.Skip) - require.EqualValues(t, subject, r.Subject) - return nil - }), - acceptConsentHandler(t, c, subject, func(r *hydra.OAuth2ConsentRequest) { - require.True(t, *r.Skip) - require.EqualValues(t, subject, *r.Subject) - }), - ) - - t.Run("followup=checks if authenticatedAt/requestedAt is properly forwarded across the lifecycle by checking if prompt=none works", func(t *testing.T) { - // In order to check if authenticatedAt/requestedAt works, we'll sleep first in order to ensure that authenticatedAt is in the past - // if handled correctly. - time.Sleep(time.Second + time.Nanosecond) - - code, _ := getAuthorizeCode(t, conf, oc, - oauth2.SetAuthURLParam("nonce", nonce), - oauth2.SetAuthURLParam("prompt", "none"), - oauth2.SetAuthURLParam("max_age", "60"), - ) - require.NotEmpty(t, code) - token, err := conf.Exchange(context.Background(), code) - require.NoError(t, err) - original := introspectAccessToken(t, conf, token, subject) - - t.Run("followup=run the flow three more times", func(t *testing.T) { - for i := 0; i < 3; i++ { - t.Run(fmt.Sprintf("run=%d", i), func(t *testing.T) { + code, _ := getAuthorizeCode(t, conf, oc, + oauth2.SetAuthURLParam("nonce", nonce), + oauth2.SetAuthURLParam("prompt", "none"), + oauth2.SetAuthURLParam("max_age", "60"), + ) + require.NotEmpty(t, code) + token, err := conf.Exchange(context.Background(), code) + require.NoError(t, err) + original := introspectAccessToken(t, conf, token, subject) + + t.Run("followup=run the flow three more times", func(t *testing.T) { + for i := 0; i < 3; i++ { + t.Run(fmt.Sprintf("run=%d", i), func(t *testing.T) { + code, _ := getAuthorizeCode(t, conf, oc, + oauth2.SetAuthURLParam("nonce", nonce), + oauth2.SetAuthURLParam("prompt", "none"), + oauth2.SetAuthURLParam("max_age", "60"), + ) + require.NotEmpty(t, code) + token, err := conf.Exchange(context.Background(), code) + require.NoError(t, err) + followup := introspectAccessToken(t, conf, token, subject) + assert.Equal(t, original.Get("auth_time").Int(), followup.Get("auth_time").Int()) + }) + } + }) + + t.Run("followup=fails when max age is reached and prompt is none", func(t *testing.T) { code, _ := getAuthorizeCode(t, conf, oc, oauth2.SetAuthURLParam("nonce", nonce), oauth2.SetAuthURLParam("prompt", "none"), - oauth2.SetAuthURLParam("max_age", "60"), + oauth2.SetAuthURLParam("max_age", "1"), + ) + require.Empty(t, code) + }) + + t.Run("followup=passes and resets skip when prompt=login", func(t *testing.T) { + testhelpers.NewLoginConsentUI(t, reg.Config(), + acceptLoginHandler(t, c, adminClient, reg, subject, func(r *hydra.OAuth2LoginRequest) *hydra.AcceptOAuth2LoginRequest { + require.False(t, r.Skip) + require.Empty(t, r.Subject) + return nil + }), + acceptConsentHandler(t, c, adminClient, reg, subject, func(r *hydra.OAuth2ConsentRequest) { + require.True(t, *r.Skip) + require.EqualValues(t, subject, *r.Subject) + }), + ) + code, _ := getAuthorizeCode(t, conf, oc, + oauth2.SetAuthURLParam("nonce", nonce), + oauth2.SetAuthURLParam("prompt", "login"), + oauth2.SetAuthURLParam("max_age", "1"), ) require.NotEmpty(t, code) token, err := conf.Exchange(context.Background(), code) require.NoError(t, err) - followup := introspectAccessToken(t, conf, token, subject) - assert.Equal(t, original.Get("auth_time").Int(), followup.Get("auth_time").Int()) + introspectAccessToken(t, conf, token, subject) + assertIDToken(t, token, conf, subject, nonce, time.Now().Add(reg.Config().GetIDTokenLifespan(ctx))) }) - } + }) }) - t.Run("followup=fails when max age is reached and prompt is none", func(t *testing.T) { + t.Run("case=should fail if prompt=none but no auth session given", func(t *testing.T) { + c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) + testhelpers.NewLoginConsentUI(t, reg.Config(), + acceptLoginHandler(t, c, adminClient, reg, subject, nil), + acceptConsentHandler(t, c, adminClient, reg, subject, nil), + ) + + oc := testhelpers.NewEmptyJarClient(t) code, _ := getAuthorizeCode(t, conf, oc, - oauth2.SetAuthURLParam("nonce", nonce), oauth2.SetAuthURLParam("prompt", "none"), - oauth2.SetAuthURLParam("max_age", "1"), ) require.Empty(t, code) }) - t.Run("followup=passes and resets skip when prompt=login", func(t *testing.T) { + t.Run("case=requires re-authentication when id_token_hint is set to a user 'patrik-neu' but the session is 'aeneas-rekkas' and then fails because the user id from the log in endpoint is 'aeneas-rekkas'", func(t *testing.T) { + c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) testhelpers.NewLoginConsentUI(t, reg.Config(), - acceptLoginHandler(t, c, subject, func(r *hydra.OAuth2LoginRequest) *hydra.AcceptOAuth2LoginRequest { + acceptLoginHandler(t, c, adminClient, reg, subject, func(r *hydra.OAuth2LoginRequest) *hydra.AcceptOAuth2LoginRequest { require.False(t, r.Skip) require.Empty(t, r.Subject) return nil }), - acceptConsentHandler(t, c, subject, func(r *hydra.OAuth2ConsentRequest) { - require.True(t, *r.Skip) - require.EqualValues(t, subject, *r.Subject) + acceptConsentHandler(t, c, adminClient, reg, subject, nil), + ) + + oc := testhelpers.NewEmptyJarClient(t) + + // Create login session for aeneas-rekkas + code, _ := getAuthorizeCode(t, conf, oc) + require.NotEmpty(t, code) + + // Perform authentication for aeneas-rekkas which fails because id_token_hint is patrik-neu + code, _ = getAuthorizeCode(t, conf, oc, + oauth2.SetAuthURLParam("id_token_hint", testhelpers.NewIDToken(t, reg, "patrik-neu")), + ) + require.Empty(t, code) + }) + + t.Run("case=should not cause issues if max_age is very low and consent takes a long time", func(t *testing.T) { + c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) + testhelpers.NewLoginConsentUI(t, reg.Config(), + acceptLoginHandler(t, c, adminClient, reg, subject, func(r *hydra.OAuth2LoginRequest) *hydra.AcceptOAuth2LoginRequest { + time.Sleep(time.Second * 2) + return nil }), + acceptConsentHandler(t, c, adminClient, reg, subject, nil), ) - code, _ := getAuthorizeCode(t, conf, oc, - oauth2.SetAuthURLParam("nonce", nonce), - oauth2.SetAuthURLParam("prompt", "login"), - oauth2.SetAuthURLParam("max_age", "1"), + + code, _ := getAuthorizeCode(t, conf, nil) + require.NotEmpty(t, code) + }) + + t.Run("case=ensure consistent claims returned for userinfo", func(t *testing.T) { + c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) + testhelpers.NewLoginConsentUI(t, reg.Config(), + acceptLoginHandler(t, c, adminClient, reg, subject, nil), + acceptConsentHandler(t, c, adminClient, reg, subject, nil), ) + + code, _ := getAuthorizeCode(t, conf, nil) require.NotEmpty(t, code) + token, err := conf.Exchange(context.Background(), code) require.NoError(t, err) - introspectAccessToken(t, conf, token, subject) - assertIDToken(t, token, conf, subject, nonce, time.Now().Add(reg.Config().GetIDTokenLifespan(ctx))) + + idClaims := assertIDToken(t, token, conf, subject, "", time.Now().Add(reg.Config().GetIDTokenLifespan(ctx))) + + uiClaims := testhelpers.Userinfo(t, token, publicTS) + + for _, f := range []string{ + "sub", + "iss", + "aud", + "bar", + "auth_time", + } { + assert.NotEmpty(t, uiClaims.Get(f).Raw, "%s: %s", f, uiClaims) + assert.EqualValues(t, idClaims.Get(f).Raw, uiClaims.Get(f).Raw, "%s\nuserinfo: %s\nidtoken: %s", f, uiClaims, idClaims) + } + + for _, f := range []string{ + "at_hash", + "c_hash", + "nonce", + "sid", + "jti", + } { + assert.Empty(t, uiClaims.Get(f).Raw, "%s: %s", f, uiClaims) + } }) - }) - }) - t.Run("case=should fail if prompt=none but no auth session given", func(t *testing.T) { - c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) - testhelpers.NewLoginConsentUI(t, reg.Config(), - acceptLoginHandler(t, c, subject, nil), - acceptConsentHandler(t, c, subject, nil), - ) - - oc := testhelpers.NewEmptyJarClient(t) - code, _ := getAuthorizeCode(t, conf, oc, - oauth2.SetAuthURLParam("prompt", "none"), - ) - require.Empty(t, code) - }) + t.Run("case=add ext claims from hook if configured", func(t *testing.T) { + run := func(strategy string) func(t *testing.T) { + return func(t *testing.T) { + hs := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("Content-Type"), "application/json; charset=UTF-8") + assert.Equal(t, r.Header.Get("Authorization"), "Bearer secret value") + + var hookReq hydraoauth2.TokenHookRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&hookReq)) + require.NotEmpty(t, hookReq.Session) + require.Equal(t, map[string]interface{}{"foo": "bar"}, hookReq.Session.Extra) + require.NotEmpty(t, hookReq.Request) + require.ElementsMatch(t, []string{}, hookReq.Request.GrantedAudience) + require.Equal(t, map[string][]string{"grant_type": {"authorization_code"}}, hookReq.Request.Payload) + + claims := map[string]interface{}{ + "hooked": true, + } - t.Run("case=requires re-authentication when id_token_hint is set to a user 'patrik-neu' but the session is 'aeneas-rekkas' and then fails because the user id from the log in endpoint is 'aeneas-rekkas'", func(t *testing.T) { - c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) - testhelpers.NewLoginConsentUI(t, reg.Config(), - acceptLoginHandler(t, c, subject, func(r *hydra.OAuth2LoginRequest) *hydra.AcceptOAuth2LoginRequest { - require.False(t, r.Skip) - require.Empty(t, r.Subject) - return nil - }), - acceptConsentHandler(t, c, subject, nil), - ) - - oc := testhelpers.NewEmptyJarClient(t) - - // Create login session for aeneas-rekkas - code, _ := getAuthorizeCode(t, conf, oc) - require.NotEmpty(t, code) - - // Perform authentication for aeneas-rekkas which fails because id_token_hint is patrik-neu - code, _ = getAuthorizeCode(t, conf, oc, - oauth2.SetAuthURLParam("id_token_hint", testhelpers.NewIDToken(t, reg, "patrik-neu")), - ) - require.Empty(t, code) - }) + hookResp := hydraoauth2.TokenHookResponse{ + Session: flow.AcceptOAuth2ConsentRequestSession{ + AccessToken: claims, + IDToken: claims, + }, + } - t.Run("case=should not cause issues if max_age is very low and consent takes a long time", func(t *testing.T) { - c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) - testhelpers.NewLoginConsentUI(t, reg.Config(), - acceptLoginHandler(t, c, subject, func(r *hydra.OAuth2LoginRequest) *hydra.AcceptOAuth2LoginRequest { - time.Sleep(time.Second * 2) - return nil - }), - acceptConsentHandler(t, c, subject, nil), - ) - - code, _ := getAuthorizeCode(t, conf, nil) - require.NotEmpty(t, code) - }) + w.WriteHeader(http.StatusOK) + require.NoError(t, json.NewEncoder(w).Encode(&hookResp)) + })) + defer hs.Close() + + reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, strategy) + reg.Config().MustSet(ctx, config.KeyTokenHook, &config.HookConfig{ + URL: hs.URL, + Auth: &config.Auth{ + Type: "api_key", + Config: config.AuthConfig{ + In: "header", + Name: "Authorization", + Value: "Bearer secret value", + }, + }, + }) - t.Run("case=ensure consistent claims returned for userinfo", func(t *testing.T) { - c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) - testhelpers.NewLoginConsentUI(t, reg.Config(), - acceptLoginHandler(t, c, subject, nil), - acceptConsentHandler(t, c, subject, nil), - ) + t.Cleanup(func() { + reg.Config().Delete(ctx, config.KeyTokenHook) + }) - code, _ := getAuthorizeCode(t, conf, nil) - require.NotEmpty(t, code) + expectAud := "https://api.ory.sh/" + c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) + testhelpers.NewLoginConsentUI(t, reg.Config(), + acceptLoginHandler(t, c, adminClient, reg, subject, func(r *hydra.OAuth2LoginRequest) *hydra.AcceptOAuth2LoginRequest { + assert.False(t, r.Skip) + assert.EqualValues(t, []string{expectAud}, r.RequestedAccessTokenAudience) + return nil + }), + acceptConsentHandler(t, c, adminClient, reg, subject, func(r *hydra.OAuth2ConsentRequest) { + assert.False(t, *r.Skip) + assert.EqualValues(t, []string{expectAud}, r.RequestedAccessTokenAudience) + })) + + code, _ := getAuthorizeCode(t, conf, nil, + oauth2.SetAuthURLParam("audience", "https://api.ory.sh/"), + oauth2.SetAuthURLParam("nonce", nonce)) + require.NotEmpty(t, code) - token, err := conf.Exchange(context.Background(), code) - require.NoError(t, err) + token, err := conf.Exchange(context.Background(), code) + require.NoError(t, err) - idClaims := assertIDToken(t, token, conf, subject, "", time.Now().Add(reg.Config().GetIDTokenLifespan(ctx))) + assertJWTAccessToken(t, strategy, conf, token, subject, time.Now().Add(reg.Config().GetAccessTokenLifespan(ctx)), `["hydra","offline","openid"]`) - time.Sleep(time.Second) - uiClaims := testhelpers.Userinfo(t, token, publicTS) + // NOTE: using introspect to cover both jwt and opaque strategies + accessTokenClaims := introspectAccessToken(t, conf, token, subject) + require.True(t, accessTokenClaims.Get("ext.hooked").Bool()) - for _, f := range []string{ - "sub", - "iss", - "aud", - "bar", - "auth_time", - } { - assert.NotEmpty(t, uiClaims.Get(f).Raw, "%s: %s", f, uiClaims) - assert.EqualValues(t, idClaims.Get(f).Raw, uiClaims.Get(f).Raw, "%s\nuserinfo: %s\nidtoken: %s", f, uiClaims, idClaims) - } + idTokenClaims := assertIDToken(t, token, conf, subject, nonce, time.Now().Add(reg.Config().GetIDTokenLifespan(ctx))) + require.True(t, idTokenClaims.Get("hooked").Bool()) + } + } - for _, f := range []string{ - "at_hash", - "c_hash", - "nonce", - "sid", - "jti", - } { - assert.Empty(t, uiClaims.Get(f).Raw, "%s: %s", f, uiClaims) - } - }) + t.Run("strategy=opaque", run("opaque")) + t.Run("strategy=jwt", run("jwt")) + }) + + t.Run("case=fail token exchange if hook fails", func(t *testing.T) { + run := func(strategy string) func(t *testing.T) { + return func(t *testing.T) { + hs := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer hs.Close() + + reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, strategy) + reg.Config().MustSet(ctx, config.KeyTokenHook, hs.URL) + + defer reg.Config().MustSet(ctx, config.KeyTokenHook, nil) + + expectAud := "https://api.ory.sh/" + c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) + testhelpers.NewLoginConsentUI(t, reg.Config(), + acceptLoginHandler(t, c, adminClient, reg, subject, func(r *hydra.OAuth2LoginRequest) *hydra.AcceptOAuth2LoginRequest { + assert.False(t, r.Skip) + assert.EqualValues(t, []string{expectAud}, r.RequestedAccessTokenAudience) + return nil + }), + acceptConsentHandler(t, c, adminClient, reg, subject, func(r *hydra.OAuth2ConsentRequest) { + assert.False(t, *r.Skip) + assert.EqualValues(t, []string{expectAud}, r.RequestedAccessTokenAudience) + })) + + code, _ := getAuthorizeCode(t, conf, nil, + oauth2.SetAuthURLParam("audience", "https://api.ory.sh/"), + oauth2.SetAuthURLParam("nonce", nonce)) + require.NotEmpty(t, code) - t.Run("case=add ext claims from hook if configured", func(t *testing.T) { - run := func(strategy string) func(t *testing.T) { - return func(t *testing.T) { - hs := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, r.Header.Get("Content-Type"), "application/json; charset=UTF-8") - assert.Equal(t, r.Header.Get("Authorization"), "Bearer secret value") - - var hookReq hydraoauth2.TokenHookRequest - require.NoError(t, json.NewDecoder(r.Body).Decode(&hookReq)) - require.NotEmpty(t, hookReq.Session) - require.Equal(t, map[string]interface{}{"foo": "bar"}, hookReq.Session.Extra) - require.NotEmpty(t, hookReq.Request) - require.ElementsMatch(t, []string{}, hookReq.Request.GrantedAudience) - require.Equal(t, map[string][]string{"grant_type": {"authorization_code"}}, hookReq.Request.Payload) - - claims := map[string]interface{}{ - "hooked": true, + _, err := conf.Exchange(context.Background(), code) + require.Error(t, err) } + } - hookResp := hydraoauth2.TokenHookResponse{ - Session: flow.AcceptOAuth2ConsentRequestSession{ - AccessToken: claims, - IDToken: claims, - }, + t.Run("strategy=opaque", run("opaque")) + t.Run("strategy=jwt", run("jwt")) + }) + + t.Run("case=fail token exchange if hook denies the request", func(t *testing.T) { + run := func(strategy string) func(t *testing.T) { + return func(t *testing.T) { + hs := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer hs.Close() + + reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, strategy) + reg.Config().MustSet(ctx, config.KeyTokenHook, hs.URL) + + defer reg.Config().MustSet(ctx, config.KeyTokenHook, nil) + + expectAud := "https://api.ory.sh/" + c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) + testhelpers.NewLoginConsentUI(t, reg.Config(), + acceptLoginHandler(t, c, adminClient, reg, subject, func(r *hydra.OAuth2LoginRequest) *hydra.AcceptOAuth2LoginRequest { + assert.False(t, r.Skip) + assert.EqualValues(t, []string{expectAud}, r.RequestedAccessTokenAudience) + return nil + }), + acceptConsentHandler(t, c, adminClient, reg, subject, func(r *hydra.OAuth2ConsentRequest) { + assert.False(t, *r.Skip) + assert.EqualValues(t, []string{expectAud}, r.RequestedAccessTokenAudience) + })) + + code, _ := getAuthorizeCode(t, conf, nil, + oauth2.SetAuthURLParam("audience", "https://api.ory.sh/"), + oauth2.SetAuthURLParam("nonce", nonce)) + require.NotEmpty(t, code) + + _, err := conf.Exchange(context.Background(), code) + require.Error(t, err) } + } - w.WriteHeader(http.StatusOK) - require.NoError(t, json.NewEncoder(w).Encode(&hookResp)) - })) - defer hs.Close() - - reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, strategy) - reg.Config().MustSet(ctx, config.KeyTokenHook, &config.HookConfig{ - URL: hs.URL, - Auth: &config.Auth{ - Type: "api_key", - Config: config.AuthConfig{ - In: "header", - Name: "Authorization", - Value: "Bearer secret value", - }, - }, + t.Run("strategy=opaque", run("opaque")) + t.Run("strategy=jwt", run("jwt")) + }) + + t.Run("case=fail token exchange if hook response is malformed", func(t *testing.T) { + run := func(strategy string) func(t *testing.T) { + return func(t *testing.T) { + hs := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer hs.Close() + + reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, strategy) + reg.Config().MustSet(ctx, config.KeyTokenHook, hs.URL) + + defer reg.Config().MustSet(ctx, config.KeyTokenHook, nil) + + expectAud := "https://api.ory.sh/" + c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) + testhelpers.NewLoginConsentUI(t, reg.Config(), + acceptLoginHandler(t, c, adminClient, reg, subject, func(r *hydra.OAuth2LoginRequest) *hydra.AcceptOAuth2LoginRequest { + assert.False(t, r.Skip) + assert.EqualValues(t, []string{expectAud}, r.RequestedAccessTokenAudience) + return nil + }), + acceptConsentHandler(t, c, adminClient, reg, subject, func(r *hydra.OAuth2ConsentRequest) { + assert.False(t, *r.Skip) + assert.EqualValues(t, []string{expectAud}, r.RequestedAccessTokenAudience) + })) + + code, _ := getAuthorizeCode(t, conf, nil, + oauth2.SetAuthURLParam("audience", "https://api.ory.sh/"), + oauth2.SetAuthURLParam("nonce", nonce)) + require.NotEmpty(t, code) + + _, err := conf.Exchange(context.Background(), code) + require.Error(t, err) + } + } + + t.Run("strategy=opaque", run("opaque")) + t.Run("strategy=jwt", run("jwt")) + }) + + t.Run("case=graceful token rotation", func(t *testing.T) { + reg.Config().MustSet(ctx, config.KeyRefreshTokenRotationGracePeriod, "2s") + reg.Config().Delete(ctx, config.KeyTokenHook) + reg.Config().Delete(ctx, config.KeyRefreshTokenHook) + reg.Config().MustSet(ctx, config.KeyRefreshTokenLifespan, "1m") + reg.Config().MustSet(ctx, config.KeyAccessTokenLifespan, "1m") + t.Cleanup(func() { + reg.Config().Delete(ctx, config.KeyRefreshTokenRotationGracePeriod) + reg.Config().Delete(ctx, config.KeyRefreshTokenLifespan) + reg.Config().Delete(ctx, config.KeyAccessTokenLifespan) }) - defer reg.Config().MustSet(ctx, config.KeyTokenHook, nil) + // This is an essential and complex test suite. We need to cover the following cases: + // + // * Graceful refresh token rotation invalidates the previous access token. + // * An expired refresh token cannot be used even if grace period is active. + // * A used refresh token cannot be re-used once the grace period ends, and it triggers re-use detection. + // * A test suite with a variety of concurrent refresh token chains. + run := func(t *testing.T, strategy string) { + c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) + testhelpers.NewLoginConsentUI(t, reg.Config(), + acceptLoginHandler(t, c, adminClient, reg, subject, nil), + acceptConsentHandler(t, c, adminClient, reg, subject, nil), + ) + + issueTokens := func(t *testing.T) *oauth2.Token { + code, _ := getAuthorizeCode(t, conf, nil, oauth2.SetAuthURLParam("nonce", nonce)) + require.NotEmpty(t, code) + token, err := conf.Exchange(context.Background(), code) + iat := time.Now() + require.NoError(t, err) - expectAud := "https://api.ory.sh/" - c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) - testhelpers.NewLoginConsentUI(t, reg.Config(), - acceptLoginHandler(t, c, subject, func(r *hydra.OAuth2LoginRequest) *hydra.AcceptOAuth2LoginRequest { - assert.False(t, r.Skip) - assert.EqualValues(t, []string{expectAud}, r.RequestedAccessTokenAudience) - return nil - }), - acceptConsentHandler(t, c, subject, func(r *hydra.OAuth2ConsentRequest) { - assert.False(t, *r.Skip) - assert.EqualValues(t, []string{expectAud}, r.RequestedAccessTokenAudience) - })) + introspectAccessToken(t, conf, token, subject) + assertJWTAccessToken(t, strategy, conf, token, subject, iat.Add(reg.Config().GetAccessTokenLifespan(ctx)), `["hydra","offline","openid"]`) + assertIDToken(t, token, conf, subject, nonce, iat.Add(reg.Config().GetIDTokenLifespan(ctx))) + assertRefreshToken(t, token, conf, iat.Add(reg.Config().GetRefreshTokenLifespan(ctx))) + return token + } - code, _ := getAuthorizeCode(t, conf, nil, - oauth2.SetAuthURLParam("audience", "https://api.ory.sh/"), - oauth2.SetAuthURLParam("nonce", nonce)) - require.NotEmpty(t, code) + refreshTokens := func(t *testing.T, token *oauth2.Token) *oauth2.Token { + require.NotEmpty(t, token.RefreshToken) + token.Expiry = time.Now().Add(-time.Hour * 24) + iat := time.Now() + refreshedToken, err := conf.TokenSource(context.Background(), token).Token() + require.NoError(t, err) - token, err := conf.Exchange(context.Background(), code) - require.NoError(t, err) + require.NotEqual(t, token.AccessToken, refreshedToken.AccessToken) + require.NotEqual(t, token.RefreshToken, refreshedToken.RefreshToken) + require.NotEqual(t, token.Extra("id_token"), refreshedToken.Extra("id_token")) - assertJWTAccessToken(t, strategy, conf, token, subject, time.Now().Add(reg.Config().GetAccessTokenLifespan(ctx)), `["hydra","offline","openid"]`) + introspectAccessToken(t, conf, refreshedToken, subject) + assertJWTAccessToken(t, strategy, conf, refreshedToken, subject, iat.Add(reg.Config().GetAccessTokenLifespan(ctx)), `["hydra","offline","openid"]`) + assertIDToken(t, refreshedToken, conf, subject, nonce, iat.Add(reg.Config().GetIDTokenLifespan(ctx))) + assertRefreshToken(t, refreshedToken, conf, iat.Add(reg.Config().GetRefreshTokenLifespan(ctx))) + return refreshedToken + } - // NOTE: using introspect to cover both jwt and opaque strategies - accessTokenClaims := introspectAccessToken(t, conf, token, subject) - require.True(t, accessTokenClaims.Get("ext.hooked").Bool()) + assertInactive := func(t *testing.T, token string, c *oauth2.Config) { + t.Helper() + at := testhelpers.IntrospectToken(t, conf, token, adminTS) + assert.False(t, at.Get("active").Bool(), "%s", at) + } - idTokenClaims := assertIDToken(t, token, conf, subject, nonce, time.Now().Add(reg.Config().GetIDTokenLifespan(ctx))) - require.True(t, idTokenClaims.Get("hooked").Bool()) - } - } + t.Run("gracefully refreshing a token does invalidate the previous access token", func(t *testing.T) { + reg.Config().MustSet(ctx, config.KeyRefreshTokenRotationGracePeriod, "2s") + reg.Config().MustSet(ctx, config.KeyRefreshTokenLifespan, "1m") - t.Run("strategy=opaque", run("opaque")) - t.Run("strategy=jwt", run("jwt")) - }) + token := issueTokens(t) + _ = refreshTokens(t, token) - t.Run("case=fail token exchange if hook fails", func(t *testing.T) { - run := func(strategy string) func(t *testing.T) { - return func(t *testing.T) { - hs := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer hs.Close() + assertInactive(t, token.AccessToken, conf) // Original access token is invalid - reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, strategy) - reg.Config().MustSet(ctx, config.KeyTokenHook, hs.URL) + _ = refreshTokens(t, token) + assertInactive(t, token.AccessToken, conf) // Original access token is still invalid + }) - defer reg.Config().MustSet(ctx, config.KeyTokenHook, nil) + t.Run("an expired refresh token can not be used even if we are in the grace period", func(t *testing.T) { + reg.Config().MustSet(ctx, config.KeyRefreshTokenRotationGracePeriod, "5s") + reg.Config().MustSet(ctx, config.KeyRefreshTokenLifespan, "1s") - expectAud := "https://api.ory.sh/" - c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) - testhelpers.NewLoginConsentUI(t, reg.Config(), - acceptLoginHandler(t, c, subject, func(r *hydra.OAuth2LoginRequest) *hydra.AcceptOAuth2LoginRequest { - assert.False(t, r.Skip) - assert.EqualValues(t, []string{expectAud}, r.RequestedAccessTokenAudience) - return nil - }), - acceptConsentHandler(t, c, subject, func(r *hydra.OAuth2ConsentRequest) { - assert.False(t, *r.Skip) - assert.EqualValues(t, []string{expectAud}, r.RequestedAccessTokenAudience) - })) + token := issueTokens(t) + time.Sleep(time.Second * 2) // Let token expire - we need 2 seconds to reliably be longer than TTL - code, _ := getAuthorizeCode(t, conf, nil, - oauth2.SetAuthURLParam("audience", "https://api.ory.sh/"), - oauth2.SetAuthURLParam("nonce", nonce)) - require.NotEmpty(t, code) + token.Expiry = time.Now().Add(-time.Hour * 24) + _, err := conf.TokenSource(ctx, token).Token() + require.Error(t, err, "Rotating an expired token is not possible even when we are in the grace period") - _, err := conf.Exchange(context.Background(), code) - require.Error(t, err) - } - } + // The access token is still valid because using an expired refresh token has no effect on the access token. + assertInactive(t, token.RefreshToken, conf) + }) - t.Run("strategy=opaque", run("opaque")) - t.Run("strategy=jwt", run("jwt")) - }) + t.Run("a used refresh token can not be re-used once the grace period ends and it triggers re-use detection", func(t *testing.T) { + reg.Config().MustSet(ctx, config.KeyRefreshTokenRotationGracePeriod, "1s") + reg.Config().MustSet(ctx, config.KeyRefreshTokenLifespan, "1m") - t.Run("case=fail token exchange if hook denies the request", func(t *testing.T) { - run := func(strategy string) func(t *testing.T) { - return func(t *testing.T) { - hs := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusForbidden) - })) - defer hs.Close() + token := issueTokens(t) + refreshed := refreshTokens(t, token) - reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, strategy) - reg.Config().MustSet(ctx, config.KeyTokenHook, hs.URL) + time.Sleep(time.Second * 2) // Wait for the grace period to end - defer reg.Config().MustSet(ctx, config.KeyTokenHook, nil) + token.Expiry = time.Now().Add(-time.Hour * 24) + _, err := conf.TokenSource(ctx, token).Token() + require.Error(t, err, "Rotating a used refresh token is not possible after the grace period") - expectAud := "https://api.ory.sh/" - c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) - testhelpers.NewLoginConsentUI(t, reg.Config(), - acceptLoginHandler(t, c, subject, func(r *hydra.OAuth2LoginRequest) *hydra.AcceptOAuth2LoginRequest { - assert.False(t, r.Skip) - assert.EqualValues(t, []string{expectAud}, r.RequestedAccessTokenAudience) - return nil - }), - acceptConsentHandler(t, c, subject, func(r *hydra.OAuth2ConsentRequest) { - assert.False(t, *r.Skip) - assert.EqualValues(t, []string{expectAud}, r.RequestedAccessTokenAudience) - })) + assertInactive(t, token.AccessToken, conf) + assertInactive(t, token.RefreshToken, conf) - code, _ := getAuthorizeCode(t, conf, nil, - oauth2.SetAuthURLParam("audience", "https://api.ory.sh/"), - oauth2.SetAuthURLParam("nonce", nonce)) - require.NotEmpty(t, code) + assertInactive(t, refreshed.AccessToken, conf) + assertInactive(t, refreshed.RefreshToken, conf) + }) - _, err := conf.Exchange(context.Background(), code) - require.Error(t, err) - } - } + // This test suite covers complex scenarios where we have multiple generations of tokens and we need to ensure + // that key security mitigations are in place: + // + // - Token re-use detection clears all tokens if a refresh token is re-used after the grace period. + // - Revoking consent clears all tokens. + // - Token revokation clears all tokens. + // + // The test creates 4 token generations, where each generations has twice as many tokens as the previous generation. + // The generations are created like this: + // + // - In the first scenario, all token generations are created at the same time. + // - In the second scenario, we create token generations with a delay that is longer than the grace period between them. + // + // Tokens for each generation are created in parallel to ensure we have no state leak anywhere.0 + t.Run("token generations", func(t *testing.T) { + + gracePeriod := time.Second + aboveGracePeriod := time.Second * 2 + reg.Config().MustSet(ctx, config.KeyRefreshTokenLifespan, "1m") + reg.Config().MustSet(ctx, config.KeyRefreshTokenRotationGracePeriod, gracePeriod.String()) + reg.Config().Delete(ctx, config.KeyTokenHook) + reg.Config().Delete(ctx, config.KeyRefreshTokenHook) + + createTokenGenerations := func(t *testing.T, count int, withSleep time.Duration) [][]*oauth2.Token { + generations := make([][]*oauth2.Token, count) + generations[0] = []*oauth2.Token{issueTokens(t)} + // Start from the first generation. For every next generation, we refresh all the tokens of the previous generation twice. + for i := 1; i < len(generations); i++ { + generations[i] = make([]*oauth2.Token, 0, len(generations[i-1])*2) + + var wg sync.WaitGroup + gen := func(i int, token *oauth2.Token) { + defer wg.Done() + generations[i] = append(generations[i], refreshTokens(t, token)) + } - t.Run("strategy=opaque", run("opaque")) - t.Run("strategy=jwt", run("jwt")) - }) + for _, token := range generations[i-1] { + wg.Add(2) + if dbName != "cockroach" { + // We currently only support TX retries on cockroach + gen(i, token) + gen(i, token) + } else { + go gen(i, token) + go gen(i, token) + } + } - t.Run("case=fail token exchange if hook response is malformed", func(t *testing.T) { - run := func(strategy string) func(t *testing.T) { - return func(t *testing.T) { - hs := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer hs.Close() + wg.Wait() + if withSleep > 0 { + time.Sleep(withSleep) + } + } + return generations + } - reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, strategy) - reg.Config().MustSet(ctx, config.KeyTokenHook, hs.URL) + t.Run("re-using an old graceful refresh token invalidates all tokens", func(t *testing.T) { + reg.Config().MustSet(ctx, config.KeyRefreshTokenRotationGracePeriod, "1s") + reg.Config().MustSet(ctx, config.KeyRefreshTokenLifespan, "1m") + // This test only works if the refresh token lifespan is longer than the grace period. + generations := createTokenGenerations(t, 4, time.Second*2) + + generationIndex := rng.Intn(len(generations) - 1) // Exclude the last generation + tokenIndex := rng.Intn(len(generations[generationIndex])) + + token := generations[generationIndex][tokenIndex] + token.Expiry = time.Now().Add(-time.Hour * 24) + _, err := conf.TokenSource(ctx, token).Token() + require.Error(t, err) + + // Now all tokens are inactive + for i, generation := range generations { + t.Run(fmt.Sprintf("generation=%d", i), func(t *testing.T) { + for j, token := range generation { + t.Run(fmt.Sprintf("token=%d", j), func(t *testing.T) { + assertInactive(t, token.AccessToken, conf) + assertInactive(t, token.RefreshToken, conf) + }) + } + }) + } + }) - defer reg.Config().MustSet(ctx, config.KeyTokenHook, nil) + for _, withSleep := range []time.Duration{0, aboveGracePeriod} { + t.Run(fmt.Sprintf("withSleep=%s", withSleep), func(t *testing.T) { + createTokenGenerations := func(t *testing.T, count int) [][]*oauth2.Token { + return createTokenGenerations(t, count, withSleep) + } - expectAud := "https://api.ory.sh/" - c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) - testhelpers.NewLoginConsentUI(t, reg.Config(), - acceptLoginHandler(t, c, subject, func(r *hydra.OAuth2LoginRequest) *hydra.AcceptOAuth2LoginRequest { - assert.False(t, r.Skip) - assert.EqualValues(t, []string{expectAud}, r.RequestedAccessTokenAudience) - return nil - }), - acceptConsentHandler(t, c, subject, func(r *hydra.OAuth2ConsentRequest) { - assert.False(t, *r.Skip) - assert.EqualValues(t, []string{expectAud}, r.RequestedAccessTokenAudience) - })) + t.Run("only the most recent token generation is valid across the board", func(t *testing.T) { + generations := createTokenGenerations(t, 4) + + // All generations except the last one are valid. + for i, generation := range generations[:len(generations)-1] { + t.Run(fmt.Sprintf("generation=%d", i), func(t *testing.T) { + for j, token := range generation { + t.Run(fmt.Sprintf("token=%d", j), func(t *testing.T) { + assertInactive(t, token.AccessToken, conf) + }) + } + }) + } - code, _ := getAuthorizeCode(t, conf, nil, - oauth2.SetAuthURLParam("audience", "https://api.ory.sh/"), - oauth2.SetAuthURLParam("nonce", nonce)) - require.NotEmpty(t, code) + // The last generation is valid: + t.Run(fmt.Sprintf("generation=%d", len(generations)-1), func(t *testing.T) { + for j, token := range generations[len(generations)-1] { + t.Run(fmt.Sprintf("token=%d", j), func(t *testing.T) { + introspectAccessToken(t, conf, token, subject) + assertIDToken(t, token, conf, subject, nonce, time.Time{}) + assertRefreshToken(t, token, conf, time.Time{}) + }) + } + }) + }) + + t.Run("revoking consent revokes all tokens", func(t *testing.T) { + generations := createTokenGenerations(t, 4) + + // After revoking consent, all generations are invalid. + err := reg.ConsentManager().RevokeSubjectConsentSession(context.Background(), subject) + require.NoError(t, err) + + for i, generation := range generations { + t.Run(fmt.Sprintf("generation=%d", i), func(t *testing.T) { + for j, token := range generation { + t.Run(fmt.Sprintf("token=%d", j), func(t *testing.T) { + assertInactive(t, token.AccessToken, conf) + assertInactive(t, token.RefreshToken, conf) + }) + } + }) + } + }) + + t.Run("re-using the a recent refresh token after the grace period has ended invalidates all tokens", func(t *testing.T) { + generations := createTokenGenerations(t, 4) + + token := generations[len(generations)-1][0] + + finalToken := refreshTokens(t, token) + time.Sleep(aboveGracePeriod) // Wait for the grace period to end + + token.Expiry = time.Now().Add(-time.Hour * 24) + _, err := conf.TokenSource(ctx, token).Token() + require.Error(t, err) + + // Now all tokens are inactive + for i, generation := range append(generations, []*oauth2.Token{finalToken}) { + t.Run(fmt.Sprintf("generation=%d", i), func(t *testing.T) { + for j, token := range generation { + t.Run(fmt.Sprintf("token=%d", j), func(t *testing.T) { + assertInactive(t, token.AccessToken, conf) + assertInactive(t, token.RefreshToken, conf) + }) + } + }) + } + }) + + t.Run("revoking a refresh token in the chain revokes all tokens", func(t *testing.T) { + generations := createTokenGenerations(t, 4) + + testhelpers.RevokeToken(t, conf, generations[len(generations)-1][0].RefreshToken, publicTS) + + for i, generation := range generations { + t.Run(fmt.Sprintf("generation=%d", i), func(t *testing.T) { + for j, token := range generation { + token := token + t.Run(fmt.Sprintf("token=%d", j), func(t *testing.T) { + assertInactive(t, token.AccessToken, conf) + assertInactive(t, token.RefreshToken, conf) + }) + } + }) + } + }) + }) + } + }) - _, err := conf.Exchange(context.Background(), code) - require.Error(t, err) - } - } + t.Run("it is possible to refresh tokens concurrently", func(t *testing.T) { + // SQLite can not handle concurrency + if dbName == "memory" { + t.Skip("Skipping test because SQLite can not handle concurrency") + } - t.Run("strategy=opaque", run("opaque")) - t.Run("strategy=jwt", run("jwt")) - }) + reg.Config().MustSet(ctx, config.KeyRefreshTokenLifespan, "1m") + reg.Config().MustSet(ctx, config.KeyRefreshTokenRotationGracePeriod, "5s") + + token := issueTokens(t) + + var wg sync.WaitGroup + refresh := func(t *testing.T, token *oauth2.Token) *oauth2.Token { + require.NotEmpty(t, token.RefreshToken) + token.Expiry = time.Now().Add(-time.Hour * 24) + tt, err := conf.TokenSource(context.Background(), token).Token() + require.NoError(t, err) + return tt + } + + refreshes := make([]*oauth2.Token, 5) + for k := range refreshes { + wg.Add(1) + go func(k int) { + defer wg.Done() + refreshes[k] = refresh(t, token) + }(k) + } + wg.Wait() + + // All tokens are valid. + for k, actual := range refreshes { + refresh := actual + require.NotEmpty(t, refresh.RefreshToken, "token %d:\ntoken:%+v", k, refresh) + require.NotEmpty(t, refresh.AccessToken, "token %d:\ntoken:%+v", k, refresh) + require.NotEmpty(t, refresh.Extra("id_token"), "token %d:\ntoken:%+v", k, refresh) + + i := testhelpers.IntrospectToken(t, conf, refresh.AccessToken, adminTS) + assert.Truef(t, i.Get("active").Bool(), "token %d:\ntoken:%+v\nresult:%s", k, refresh, i) + + i = testhelpers.IntrospectToken(t, conf, refresh.RefreshToken, adminTS) + assert.Truef(t, i.Get("active").Bool(), "token %d:\ntoken:%+v\nresult:%s", k, refresh, i) + } + }) + } + + t.Run("strategy=jwt", func(t *testing.T) { + reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "jwt") + run(t, "jwt") + }) + + t.Run("strategy=opaque", func(t *testing.T) { + reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "opaque") + run(t, "opaque") + }) + }) + }) + } } func assertCreateVerifiableCredential(t *testing.T, reg driver.Registry, nonce string, accessToken *oauth2.Token, alg jose.SignatureAlgorithm) { @@ -1365,7 +1551,7 @@ func assertCreateVerifiableCredential(t *testing.T, reg driver.Registry, nonce s proofJWT := createVCProofJWT(t, pubKeyJWK, privKey, nonce) // Assert that we can fetch a verifiable credential with the nonce. - verifiableCredential, _ := createVerifiableCredential(t, reg, accessToken, &hydraoauth2.CreateVerifiableCredentialRequestBody{ + verifiableCredential, err := createVerifiableCredential(t, reg, accessToken, &hydraoauth2.CreateVerifiableCredentialRequestBody{ Format: "jwt_vc_json", Types: []string{"VerifiableCredential", "UserInfoCredential"}, Proof: &hydraoauth2.VerifiableCredentialProof{ @@ -1414,7 +1600,7 @@ func createVerifiableCredential( reg driver.Registry, token *oauth2.Token, createVerifiableCredentialReq *hydraoauth2.CreateVerifiableCredentialRequestBody, -) (vcRes *hydraoauth2.VerifiableCredentialResponse, vcErr *fosite.RFC6749Error) { +) (vcRes *hydraoauth2.VerifiableCredentialResponse, vcErr error) { var ( ctx = context.Background() body bytes.Buffer @@ -1486,18 +1672,18 @@ func TestAuthCodeWithMockStrategy(t *testing.T) { ctx := context.Background() for _, strat := range []struct{ d string }{{d: "opaque"}, {d: "jwt"}} { t.Run("strategy="+strat.d, func(t *testing.T) { - conf := internal.NewConfigurationWithDefaults() + conf := testhelpers.NewConfigurationWithDefaults() conf.MustSet(ctx, config.KeyAccessTokenLifespan, time.Second*2) conf.MustSet(ctx, config.KeyScopeStrategy, "DEPRECATED_HIERARCHICAL_SCOPE_STRATEGY") conf.MustSet(ctx, config.KeyAccessTokenStrategy, strat.d) - reg := internal.NewRegistryMemory(t, conf, &contextx.Default{}) - internal.MustEnsureRegistryKeys(ctx, reg, x.OpenIDConnectKeyName) - internal.MustEnsureRegistryKeys(ctx, reg, x.OAuth2JWTKeyName) + reg := testhelpers.NewRegistryMemory(t, conf, &contextx.Default{}) + testhelpers.MustEnsureRegistryKeys(ctx, reg, x.OpenIDConnectKeyName) + testhelpers.MustEnsureRegistryKeys(ctx, reg, x.OAuth2JWTKeyName) consentStrategy := &consentMock{} router := x.NewRouterPublic() ts := httptest.NewServer(router) - defer ts.Close() + t.Cleanup(ts.Close) reg.WithConsentStrategy(consentStrategy) handler := reg.OAuth2Handler() @@ -1511,7 +1697,7 @@ func TestAuthCodeWithMockStrategy(t *testing.T) { }) var mutex sync.Mutex - require.NoError(t, reg.ClientManager().CreateClient(context.TODO(), &client.Client{ + require.NoError(t, reg.ClientManager().CreateClient(ctx, &client.Client{ ID: "app-client", Secret: "secret", RedirectURIs: []string{ts.URL + "/callback"}, @@ -1874,6 +2060,7 @@ func TestAuthCodeWithMockStrategy(t *testing.T) { if hookType == "legacy" { conf.MustSet(ctx, config.KeyRefreshTokenHook, hs.URL) defer conf.MustSet(ctx, config.KeyRefreshTokenHook, nil) + } else { conf.MustSet(ctx, config.KeyTokenHook, hs.URL) defer conf.MustSet(ctx, config.KeyTokenHook, nil) @@ -2033,13 +2220,13 @@ func TestAuthCodeWithMockStrategy(t *testing.T) { t.Run("refreshing old token should no longer work", func(t *testing.T) { res, err := testRefresh(t, token, ts.URL, false) require.NoError(t, err) - assert.Equal(t, http.StatusUnauthorized, res.StatusCode) + assert.Equal(t, http.StatusBadRequest, res.StatusCode) }) t.Run("attempt to refresh old token should revoke new token", func(t *testing.T) { res, err := testRefresh(t, &refreshedToken, ts.URL, false) require.NoError(t, err) - assert.Equal(t, http.StatusUnauthorized, res.StatusCode) + assert.Equal(t, http.StatusBadRequest, res.StatusCode) }) t.Run("duplicate code exchange fails", func(t *testing.T) { diff --git a/oauth2/oauth2_client_credentials_bench_test.go b/oauth2/oauth2_client_credentials_bench_test.go index 310727f34cc..560925ffb3e 100644 --- a/oauth2/oauth2_client_credentials_bench_test.go +++ b/oauth2/oauth2_client_credentials_bench_test.go @@ -22,7 +22,6 @@ import ( hc "github.com/ory/hydra/v2/client" "github.com/ory/hydra/v2/driver/config" - "github.com/ory/hydra/v2/internal" "github.com/ory/hydra/v2/internal/testhelpers" "github.com/ory/hydra/v2/x" "github.com/ory/x/contextx" @@ -36,7 +35,7 @@ func BenchmarkClientCredentials(b *testing.B) { tracer := trace.NewTracerProvider(trace.WithSpanProcessor(spans)).Tracer("") dsn := "postgres://postgres:secret@127.0.0.1:3445/postgres?sslmode=disable" - reg := internal.NewRegistrySQLFromURL(b, dsn, true, new(contextx.Default)).WithTracer(tracer) + reg := testhelpers.NewRegistrySQLFromURL(b, dsn, true, new(contextx.Default)).WithTracer(tracer) reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "opaque") public, admin := testhelpers.NewOAuth2Server(ctx, b, reg) diff --git a/oauth2/oauth2_client_credentials_test.go b/oauth2/oauth2_client_credentials_test.go index 9d5067dafb1..a93ea067716 100644 --- a/oauth2/oauth2_client_credentials_test.go +++ b/oauth2/oauth2_client_credentials_test.go @@ -28,14 +28,13 @@ import ( hc "github.com/ory/hydra/v2/client" "github.com/ory/hydra/v2/driver/config" - "github.com/ory/hydra/v2/internal" "github.com/ory/hydra/v2/x" "github.com/ory/x/requirex" ) func TestClientCredentials(t *testing.T) { ctx := context.Background() - reg := internal.NewMockedRegistry(t, &contextx.Default{}) + reg := testhelpers.NewMockedRegistry(t, &contextx.Default{}) reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "opaque") public, admin := testhelpers.NewOAuth2Server(ctx, t, reg) diff --git a/oauth2/oauth2_jwt_bearer_test.go b/oauth2/oauth2_jwt_bearer_test.go index e9e7ddf9120..0b1a862ba05 100644 --- a/oauth2/oauth2_jwt_bearer_test.go +++ b/oauth2/oauth2_jwt_bearer_test.go @@ -35,13 +35,12 @@ import ( hc "github.com/ory/hydra/v2/client" "github.com/ory/hydra/v2/driver/config" - "github.com/ory/hydra/v2/internal" "github.com/ory/hydra/v2/x" ) func TestJWTBearer(t *testing.T) { ctx := context.Background() - reg := internal.NewMockedRegistry(t, &contextx.Default{}) + reg := testhelpers.NewMockedRegistry(t, &contextx.Default{}) reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "opaque") _, admin := testhelpers.NewOAuth2Server(ctx, t, reg) diff --git a/oauth2/oauth2_refresh_token_test.go b/oauth2/oauth2_refresh_token_test.go deleted file mode 100644 index 849fae06460..00000000000 --- a/oauth2/oauth2_refresh_token_test.go +++ /dev/null @@ -1,272 +0,0 @@ -// Copyright © 2022 Ory Corp -// SPDX-License-Identifier: Apache-2.0 - -package oauth2_test - -import ( - "context" - "errors" - "fmt" - "math/rand" - "net/url" - "strings" - "sync" - "testing" - "time" - - "github.com/gofrs/uuid" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/ory/fosite" - hc "github.com/ory/hydra/v2/client" - "github.com/ory/hydra/v2/driver" - "github.com/ory/hydra/v2/internal" - "github.com/ory/hydra/v2/oauth2" - "github.com/ory/x/contextx" - "github.com/ory/x/dbal" - "github.com/ory/x/networkx" -) - -// TestCreateRefreshTokenSessionStress is a sanity test to verify the fix for https://github.com/ory/hydra/issues/1719 & -// https://github.com/ory/hydra/issues/1735. -// It currently only deals with Postgres as that was what the issue was based on due to the default isolation level used -// by the storage engine. -func TestCreateRefreshTokenSessionStress(t *testing.T) { - if testing.Short() { - return - } - - // number of iterations this test will make to ensure everything is working as expected. This test is aiming to - // prove correct behaviour when the handler is getting hit with the same refresh token in concurrent requests. Given - // that problems that may occur in this scenario are "racey" in nature, it is important to run this test several times - // so to minimize the probability were we pass due to sheer luck. - testRuns := 5 - // number of workers that will concurrently hit the 'CreateRefreshTokenSession' method using the same refresh token. - // don't set this value to be too high as it will result in connection failures to the DB instance. The test is designed such that - // it will retry in the event we get unlucky and a transaction completes successfully prior to other requests getting past the - // first read. - workers := 10 - - token := "234c678fed33c1d2025537ae464a1ebf7d23fc4a" //nolint:gosec - tokenSignature := "4c7c7e8b3a77ad0c3ec846a21653c48b45dbfa31" //nolint:gosec - testClient := hc.Client{ - ID: uuid.Must(uuid.NewV4()).String(), - Secret: "secret", - ResponseTypes: []string{"id_token", "code", "token"}, - GrantTypes: []string{"implicit", "refresh_token", "authorization_code", "password", "client_credentials"}, - Scope: "hydra offline openid", - Audience: []string{"https://api.ory.sh/"}, - } - - request := &fosite.AccessRequest{ - GrantTypes: []string{ - "refresh_token", - }, - Request: fosite.Request{ - RequestedAt: time.Now(), - ID: uuid.Must(uuid.NewV4()).String(), - Client: &hc.Client{ - ID: testClient.GetID(), - }, - RequestedScope: []string{"offline"}, - GrantedScope: []string{"offline"}, - Session: oauth2.NewSession(""), - Form: url.Values{ - "refresh_token": []string{fmt.Sprintf("%s.%s", token, tokenSignature)}, - }, - }, - } - - setupRegistries(t) - - for dbName, dbRegistry := range registries { - if dbName == "memory" { - // todo check why sqlite fails with "no such table: hydra_oauth2_refresh \n sqlite create" - // should be fine though as nobody should use sqlite in production - continue - } - net := &networkx.Network{} - require.NoError(t, dbRegistry.Persister().Connection(context.Background()).First(net)) - dbRegistry.WithContextualizer(&contextx.Static{NID: net.ID, C: internal.NewConfigurationWithDefaults().Source(context.Background())}) - - ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(30*time.Second)) - t.Cleanup(cancel) - require.NoError(t, dbRegistry.OAuth2Storage().(clientCreator).CreateClient(ctx, &testClient)) - require.NoError(t, dbRegistry.OAuth2Storage().CreateRefreshTokenSession(ctx, tokenSignature, request)) - _, err := dbRegistry.OAuth2Storage().GetRefreshTokenSession(ctx, tokenSignature, nil) - require.NoError(t, err) - provider := dbRegistry.OAuth2Provider() - storageVersion := dbVersion(t, ctx, dbRegistry) - - var wg sync.WaitGroup - for run := 0; run < testRuns; run++ { - barrier := make(chan struct{}) - errorsCh := make(chan error, workers) - - go func() { - for w := 0; w < workers; w++ { - wg.Add(1) - go func(run, worker int) { - defer wg.Done() - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond) //nolint:gosec - // all workers will block here until the for loop above has launched all the worker go-routines - // this is to ensure we fire all the workers off at the same - <-barrier - _, err := provider.NewAccessResponse(ctx, request) - errorsCh <- err - }(run, w) - } - - // wait until all workers have completed their work - wg.Wait() - close(errorsCh) - }() - - // let the race begin! - // all worker go-routines will now attempt to hit the "NewAccessResponse" method - close(barrier) - - // process worker results - - // successCount is the number of workers that were able to call "NewAccessResponse" without receiving an error. - // if the successCount at the end of a test run is bigger than one, it means that multiple access/refresh tokens - // were issued using the same refresh token! - https://knowyourmeme.com/memes/scared-hamster - var successCount int - for err := range errorsCh { - if err != nil { - if e := (&fosite.RFC6749Error{}); errors.As(err, &e) { - switch e.ErrorField { - - // change logic below when the refresh handler starts returning 'fosite.ErrInvalidRequest' for other reasons. - // as of now, this error is only returned due to concurrent transactions competing to refresh using the same token. - - case fosite.ErrInvalidRequest.ErrorField, fosite.ErrServerError.ErrorField: - // the error description copy is defined by RFC 6749 and should not be different regardless of - // the underlying transactional aware storage backend used by hydra - assert.Contains(t, []string{fosite.ErrInvalidRequest.DescriptionField, fosite.ErrServerError.DescriptionField}, e.DescriptionField) - // the database error debug copy will be different depending on the underlying database used - switch dbName { - case dbal.DriverMySQL: - case dbal.DriverPostgreSQL, dbal.DriverCockroachDB: - var matched bool - for _, errSubstr := range []string{ - // both postgreSQL & cockroachDB return error code 40001 for consistency errors as a result of - // using the REPEATABLE_READ isolation level - "SQLSTATE 40001", - // possible if one worker starts the transaction AFTER another worker has successfully - // refreshed the token and committed the transaction - "not_found", - // postgres: duplicate key value violates unique constraint "hydra_oauth2_access_request_id_idx": Unable to insert or update resource because a resource with that value exists already: The request could not be completed due to concurrent access - "duplicate key", - // cockroach: restart transaction: TransactionRetryWithProtoRefreshError: TransactionRetryError: retry txn (RETRY_WRITE_TOO_OLD - WriteTooOld flag converted to WriteTooOldError): "sql txn" meta={id=7f069400 key=/Table/62/2/"02a55d6e-509b-4d7a-8458-5828b2f831a1"/0 pri=0.00598277 epo=0 ts=1600955431.566576173,2 min=1600955431.566576173,0 seq=6} lock=true stat=PENDING rts=1600955431.566576173,2 wto=false max=1600955431.566576173,0: Unable to serialize access due to a concurrent update in another session: The request could not be completed due to concurrent access - "RETRY_WRITE_TOO_OLD", - // postgres: pq: deadlock detected - "deadlock detected", - // postgres: pq: could not serialize access due to concurrent update: Unable to serialize access due to a concurrent update in another session: The request could not be completed due to concurrent access - "concurrent update", - // cockroach: this happens when there is an error with the storage - "RETRY_WRITE_TOO_OLD", - // refresh token reuse detection - "token_inactive", - } { - if strings.Contains(e.DebugField, errSubstr) { - matched = true - break - } - } - - assert.True(t, matched, "received an unexpected kind of `%s`\n"+ - "DB version: %s\n"+ - "Error description: %s\n"+ - "Error debug: %s\n"+ - "Error hint: %s\n"+ - "Raw error: %T %+v\n"+ - "Raw cause: %T %+v", - e.ErrorField, - storageVersion, - e.DescriptionField, - e.DebugField, - e.HintField, - err, err, - e, e) - } - default: - // unfortunately, MySQL does not offer the same behaviour under the "REPEATABLE_READ" isolation - // level so we have to relax this assertion just for MySQL for the time being as server_errors - // resembling the following can be returned: - // - // Error 1213: Deadlock found when trying to get lock; try restarting transaction - if dbName != dbal.DriverMySQL { - t.Errorf("an unexpected RFC6749 error with the name %q was returned.\n"+ - "Hint: has the refresh token error handling changed in fosite? If so, you need to add further "+ - "assertions here to cover the additional errors that are being returned by the handler.\n"+ - "DB version: %s\n"+ - "Error description: %s\n"+ - "Error debug: %s\n"+ - "Error hint: %s\n"+ - "Raw error: %+v", - e.ErrorField, - storageVersion, - e.DescriptionField, - e.DebugField, - e.HintField, - err) - } - } - } else { - t.Errorf("expected underlying error to be of type '*fosite.RFC6749Error', but it was "+ - "actually of type %T: %+v - DB version: %s", err, err, storageVersion) - } - } else { - successCount++ - } - } - - // IMPORTANT - skip consistency check for MySQL :( - // - // different DBMS's provide different consistency guarantees when using the "REPEATABLE_READ" isolation level - // Currently, MySQL's implementation of "REPEATABLE_READ" makes it possible for multiple concurrent requests - // to successfully utilize the same refresh token. Therefore, we skip the assertion below. - // - // TODO: this needs to be addressed by making it possible to use different isolation levels for various authorization - // flows depending on the underlying hydra storage backend. For example, if using MySQL, hydra should force - // the transaction isolation level to be "Serializable" when a request to the token handler is received. - - switch dbName { - case dbal.DriverMySQL: - case dbal.DriverPostgreSQL, dbal.DriverCockroachDB: - require.Equal(t, 1, successCount, "CRITICAL: in test iteration %d, %d out of %d workers "+ - "were able to use the refresh token. Exactly ONE was expected to be have been successful.", - run, - successCount, - workers) - } - - // reset state for the next test iteration - assert.NoError(t, dbRegistry.OAuth2Storage().DeleteRefreshTokenSession(ctx, tokenSignature)) - assert.NoError(t, dbRegistry.OAuth2Storage().CreateRefreshTokenSession(ctx, tokenSignature, request)) - } - } -} - -type version struct { - Version string `db:"version"` -} - -func dbVersion(t *testing.T, ctx context.Context, registry driver.Registry) string { - var v version - - versionFunc := "version()" - c := registry.Persister().Connection(ctx) - if c.Dialect.Name() == "sqlite3" { - versionFunc = "sqlite_version()" - } - /* #nosec G201 - versionFunc is an enum */ - require.NoError(t, registry.Persister().Connection(ctx).RawQuery(fmt.Sprintf("select %s as version", versionFunc)).First(&v)) - - return v.Version -} diff --git a/oauth2/oauth2_rop_test.go b/oauth2/oauth2_rop_test.go index 4adb4904452..0428e86e7a1 100644 --- a/oauth2/oauth2_rop_test.go +++ b/oauth2/oauth2_rop_test.go @@ -22,7 +22,6 @@ import ( "github.com/ory/hydra/v2/driver/config" "github.com/ory/hydra/v2/flow" "github.com/ory/hydra/v2/fositex" - "github.com/ory/hydra/v2/internal" "github.com/ory/hydra/v2/internal/kratos" "github.com/ory/hydra/v2/internal/testhelpers" hydraoauth2 "github.com/ory/hydra/v2/oauth2" @@ -34,7 +33,7 @@ import ( func TestResourceOwnerPasswordGrant(t *testing.T) { ctx := context.Background() fakeKratos := kratos.NewFake() - reg := internal.NewMockedRegistry(t, &contextx.Default{}) + reg := testhelpers.NewMockedRegistry(t, &contextx.Default{}) reg.WithKratos(fakeKratos) reg.WithExtraFositeFactories([]fositex.Factory{compose.OAuth2ResourceOwnerPasswordCredentialsFactory}) publicTS, adminTS := testhelpers.NewOAuth2Server(ctx, t, reg) diff --git a/oauth2/revocator_test.go b/oauth2/revocator_test.go index 4ad0be8cac7..32283730fa9 100644 --- a/oauth2/revocator_test.go +++ b/oauth2/revocator_test.go @@ -11,6 +11,8 @@ import ( "testing" "time" + "github.com/ory/hydra/v2/internal/testhelpers" + "github.com/gobuffalo/pop/v6" "github.com/ory/x/httprouterx" @@ -60,10 +62,10 @@ func countAccessTokens(t *testing.T, c *pop.Connection) int { } func TestRevoke(t *testing.T) { - conf := internal.NewConfigurationWithDefaults() - reg := internal.NewRegistryMemory(t, conf, &contextx.Default{}) + conf := testhelpers.NewConfigurationWithDefaults() + reg := testhelpers.NewRegistryMemory(t, conf, &contextx.Default{}) - internal.MustEnsureRegistryKeys(context.Background(), reg, x.OpenIDConnectKeyName) + testhelpers.MustEnsureRegistryKeys(context.Background(), reg, x.OpenIDConnectKeyName) internal.AddFositeExamples(reg) tokens := Tokens(reg.OAuth2ProviderConfig(), 4) diff --git a/oauth2/session_custom_claims_test.go b/oauth2/session_custom_claims_test.go index 5fbe3c5c1a5..5594df88021 100644 --- a/oauth2/session_custom_claims_test.go +++ b/oauth2/session_custom_claims_test.go @@ -7,11 +7,12 @@ import ( "context" "testing" + "github.com/ory/hydra/v2/internal/testhelpers" + "github.com/ory/fosite/handler/openid" "github.com/ory/fosite/token/jwt" "github.com/ory/hydra/v2/driver/config" - "github.com/ory/hydra/v2/internal" "github.com/ory/hydra/v2/oauth2" "github.com/stretchr/testify/assert" @@ -39,7 +40,7 @@ func createSessionWithCustomClaims(ctx context.Context, p *config.DefaultProvide func TestCustomClaimsInSession(t *testing.T) { ctx := context.Background() - c := internal.NewConfigurationWithDefaults() + c := testhelpers.NewConfigurationWithDefaults() t.Run("no_custom_claims", func(t *testing.T) { c.MustSet(ctx, config.KeyAllowedTopLevelClaims, []string{}) diff --git a/oauth2/trust/handler_test.go b/oauth2/trust/handler_test.go index daacc8ed282..e93066eac97 100644 --- a/oauth2/trust/handler_test.go +++ b/oauth2/trust/handler_test.go @@ -15,6 +15,8 @@ import ( "testing" "time" + "github.com/ory/hydra/v2/internal/testhelpers" + "github.com/go-jose/go-jose/v3" "github.com/tidwall/gjson" @@ -33,7 +35,6 @@ import ( hydra "github.com/ory/hydra-client-go/v2" "github.com/ory/hydra/v2/driver/config" - "github.com/ory/hydra/v2/internal" "github.com/ory/hydra/v2/x" ) @@ -50,10 +51,10 @@ type HandlerTestSuite struct { // Setup will run before the tests in the suite are run. func (s *HandlerTestSuite) SetupSuite() { - conf := internal.NewConfigurationWithDefaults() + conf := testhelpers.NewConfigurationWithDefaults() conf.MustSet(context.Background(), config.KeySubjectTypesSupported, []string{"public"}) conf.MustSet(context.Background(), config.KeyDefaultClientScope, []string{"foo", "bar"}) - s.registry = internal.NewRegistryMemory(s.T(), conf, &contextx.Default{}) + s.registry = testhelpers.NewRegistryMemory(s.T(), conf, &contextx.Default{}) router := x.NewRouterAdmin(conf.AdminURL) handler := trust.NewHandler(s.registry) @@ -80,7 +81,7 @@ func (s *HandlerTestSuite) TearDownSuite() { // Will run after each test in the suite. func (s *HandlerTestSuite) TearDownTest() { - internal.CleanAndMigrate(s.registry)(s.T()) + testhelpers.CleanAndMigrate(s.registry)(s.T()) } // In order for 'go test' to run this suite, we need to create diff --git a/persistence/sql/migratest/migration_test.go b/persistence/sql/migratest/migration_test.go index 8564cfab969..71435d95687 100644 --- a/persistence/sql/migratest/migration_test.go +++ b/persistence/sql/migratest/migration_test.go @@ -13,7 +13,8 @@ import ( "testing" "time" - "github.com/ory/hydra/v2/internal" + "github.com/ory/hydra/v2/internal/testhelpers" + "github.com/ory/x/contextx" "github.com/bradleyjkemp/cupaloy/v2" @@ -64,7 +65,7 @@ func TestMigrations(t *testing.T) { connections := make(map[string]*pop.Connection, 1) if testing.Short() { - reg := internal.NewMockedRegistry(t, &contextx.Default{}) + reg := testhelpers.NewMockedRegistry(t, &contextx.Default{}) require.NoError(t, reg.Persister().MigrateUp(context.Background())) c := reg.Persister().Connection(context.Background()) connections["sqlite"] = c diff --git a/persistence/sql/migrations/20241129111700000000_add_refresh_token_access_token_link.autocommit.down.sql b/persistence/sql/migrations/20241129111700000000_add_refresh_token_access_token_link.autocommit.down.sql new file mode 100644 index 00000000000..46db0f98db5 --- /dev/null +++ b/persistence/sql/migrations/20241129111700000000_add_refresh_token_access_token_link.autocommit.down.sql @@ -0,0 +1 @@ +ALTER TABLE hydra_oauth2_refresh DROP COLUMN access_token_signature; diff --git a/persistence/sql/migrations/20241129111700000000_add_refresh_token_access_token_link.autocommit.up.sql b/persistence/sql/migrations/20241129111700000000_add_refresh_token_access_token_link.autocommit.up.sql new file mode 100644 index 00000000000..3b389709bc7 --- /dev/null +++ b/persistence/sql/migrations/20241129111700000000_add_refresh_token_access_token_link.autocommit.up.sql @@ -0,0 +1 @@ +ALTER TABLE hydra_oauth2_refresh ADD access_token_signature VARCHAR(255) DEFAULT NULL; diff --git a/persistence/sql/persister.go b/persistence/sql/persister.go index ba2647393a5..413e40a8eaa 100644 --- a/persistence/sql/persister.go +++ b/persistence/sql/persister.go @@ -33,7 +33,6 @@ var _ persistence.Persister = new(Persister) var _ storage.Transactional = new(Persister) var ( - ErrTransactionOpen = errors.New("There is already a Transaction in this context.") ErrNoTransactionOpen = errors.New("There is no Transaction in this context.") ) diff --git a/persistence/sql/persister_nid_test.go b/persistence/sql/persister_nid_test.go index 5d556d44b4d..93bccdcfe58 100644 --- a/persistence/sql/persister_nid_test.go +++ b/persistence/sql/persister_nid_test.go @@ -10,6 +10,8 @@ import ( "testing" "time" + "github.com/ory/hydra/v2/internal/testhelpers" + "github.com/ory/fosite/handler/openid" "github.com/stretchr/testify/assert" @@ -29,7 +31,6 @@ import ( "github.com/ory/hydra/v2/consent" "github.com/ory/hydra/v2/driver" "github.com/ory/hydra/v2/flow" - "github.com/ory/hydra/v2/internal" "github.com/ory/hydra/v2/jwk" "github.com/ory/hydra/v2/oauth2" "github.com/ory/hydra/v2/oauth2/trust" @@ -57,11 +58,11 @@ var _ interface { func (s *PersisterTestSuite) SetupSuite() { s.registries = map[string]driver.Registry{ - "memory": internal.NewRegistrySQLFromURL(s.T(), dbal.NewSQLiteTestDatabase(s.T()), true, &contextx.Default{}), + "memory": testhelpers.NewRegistrySQLFromURL(s.T(), dbal.NewSQLiteTestDatabase(s.T()), true, &contextx.Default{}), } if !testing.Short() { - s.registries["postgres"], s.registries["mysql"], s.registries["cockroach"], _ = internal.ConnectDatabases(s.T(), true, &contextx.Default{}) + s.registries["postgres"], s.registries["mysql"], s.registries["cockroach"], _ = testhelpers.ConnectDatabases(s.T(), true, &contextx.Default{}) } s.t1NID, s.t2NID = uuid.Must(uuid.NewV4()), uuid.Must(uuid.NewV4()) @@ -533,7 +534,7 @@ func (s *PersisterTestSuite) TestCreateRefreshTokenSession() { authorizeCode := uuid.Must(uuid.NewV4()).String() actual := persistencesql.OAuth2RequestSQL{Table: "refresh"} require.Error(t, r.Persister().Connection(context.Background()).Find(&actual, authorizeCode)) - require.NoError(t, r.Persister().CreateRefreshTokenSession(s.t1, authorizeCode, request)) + require.NoError(t, r.Persister().CreateRefreshTokenSession(s.t1, authorizeCode, "", request)) require.NoError(t, r.Persister().Connection(context.Background()).Find(&actual, authorizeCode)) require.Equal(t, s.t1NID, actual.NID) }) @@ -727,7 +728,7 @@ func (s *PersisterTestSuite) TestDeleteRefreshTokenSession() { request.Session = &oauth2.Session{DefaultSession: &openid.DefaultSession{Subject: "sub"}} signature := uuid.Must(uuid.NewV4()).String() - require.NoError(t, r.Persister().CreateRefreshTokenSession(s.t1, signature, request)) + require.NoError(t, r.Persister().CreateRefreshTokenSession(s.t1, signature, "", request)) actual := persistencesql.OAuth2RequestSQL{Table: "refresh"} @@ -933,7 +934,7 @@ func (s *PersisterTestSuite) TestFlushInactiveRefreshTokens() { signature := uuid.Must(uuid.NewV4()).String() require.NoError(t, r.Persister().CreateClient(s.t1, client)) - require.NoError(t, r.Persister().CreateRefreshTokenSession(s.t1, signature, request)) + require.NoError(t, r.Persister().CreateRefreshTokenSession(s.t1, signature, "", request)) actual := persistencesql.OAuth2RequestSQL{Table: "refresh"} @@ -1392,7 +1393,7 @@ func (s *PersisterTestSuite) TestGetRefreshTokenSession() { request.Session = &oauth2.Session{DefaultSession: &openid.DefaultSession{Subject: "sub"}} sig := uuid.Must(uuid.NewV4()).String() require.NoError(t, r.Persister().CreateClient(s.t1, client)) - require.NoError(t, r.Persister().CreateRefreshTokenSession(s.t1, sig, request)) + require.NoError(t, r.Persister().CreateRefreshTokenSession(s.t1, sig, "", request)) actual, err := r.Persister().GetRefreshTokenSession(s.t2, sig, &fosite.DefaultSession{}) require.Error(t, err) @@ -1777,47 +1778,114 @@ func (s *PersisterTestSuite) TestRevokeRefreshToken() { request.Session = &oauth2.Session{DefaultSession: &openid.DefaultSession{Subject: "sub"}} signature := uuid.Must(uuid.NewV4()).String() - require.NoError(t, r.Persister().CreateRefreshTokenSession(s.t1, signature, request)) - - actual := persistencesql.OAuth2RequestSQL{Table: "refresh"} + require.NoError(t, r.Persister().CreateRefreshTokenSession(s.t1, signature, "", request)) + var actualt2 persistencesql.OAuth2RefreshTable require.NoError(t, r.Persister().RevokeRefreshToken(s.t2, request.ID)) - require.NoError(t, r.Persister().Connection(context.Background()).Find(&actual, signature)) - require.Equal(t, true, actual.Active) + require.NoError(t, r.Persister().Connection(context.Background()).Find(&actualt2, signature)) + require.Equal(t, true, actualt2.Active) + require.NoError(t, r.Persister().RevokeRefreshToken(s.t1, request.ID)) - require.NoError(t, r.Persister().Connection(context.Background()).Find(&actual, signature)) - require.Equal(t, false, actual.Active) + require.ErrorIs(t, r.Persister().Connection(context.Background()).Find(new(persistencesql.OAuth2RefreshTable), signature), sql.ErrNoRows) }) } } -func (s *PersisterTestSuite) TestRevokeRefreshTokenMaybeGracePeriod() { +func (s *PersisterTestSuite) TestRotateRefreshToken() { t := s.T() for k, r := range s.registries { t.Run(k, func(t *testing.T) { - client := &client.Client{ID: "client-id"} - require.NoError(t, r.Persister().CreateClient(s.t1, client)) + t.Run("with access signature", func(t *testing.T) { + clientID := uuid.Must(uuid.NewV4()).String() + require.NoError(t, r.Persister().CreateClient(s.t1, &client.Client{ID: clientID})) + require.NoError(t, r.Persister().CreateClient(s.t2, &client.Client{ID: clientID})) - request := fosite.NewRequest() - request.Client = &fosite.DefaultClient{ID: "client-id"} - request.Session = &oauth2.Session{DefaultSession: &openid.DefaultSession{Subject: "sub"}} + request := fosite.NewRequest() + request.Client = &fosite.DefaultClient{ID: clientID} + request.Session = &oauth2.Session{DefaultSession: &openid.DefaultSession{Subject: "sub"}} - signature := uuid.Must(uuid.NewV4()).String() - require.NoError(t, r.Persister().CreateRefreshTokenSession(s.t1, signature, request)) + // Create token T1 + signatureT1 := uuid.Must(uuid.NewV4()).String() + accessSignatureT1 := uuid.Must(uuid.NewV4()).String() + require.NoError(t, r.Persister().CreateAccessTokenSession(s.t1, accessSignatureT1, request)) + require.NoError(t, r.Persister().CreateRefreshTokenSession(s.t1, signatureT1, accessSignatureT1, request)) - actual := persistencesql.OAuth2RequestSQL{Table: "refresh"} + // Create token T2 + signatureT2 := uuid.Must(uuid.NewV4()).String() + accessSignatureT2 := uuid.Must(uuid.NewV4()).String() + require.ErrorIs(t, r.Persister().RotateRefreshToken(s.t2, request.ID, signatureT2), fosite.ErrNotFound, "Rotation fails as token is non-existent.") + require.NoError(t, r.Persister().CreateAccessTokenSession(s.t2, accessSignatureT2, request)) + require.NoError(t, r.Persister().CreateRefreshTokenSession(s.t2, signatureT2, accessSignatureT2, request)) - store, ok := r.Persister().(*persistencesql.Persister) - if !ok { - t.Fatal("type assertion failed") - } + accessT2 := persistencesql.OAuth2RequestSQL{Table: "access"} + assert.NoError(t, r.Persister().Connection(s.t2).Where("signature = ?", x.SignatureHash(accessSignatureT2)).First(&accessT2)) + require.Equal(t, true, accessT2.Active) - require.NoError(t, store.RevokeRefreshTokenMaybeGracePeriod(s.t2, request.ID, signature)) - require.NoError(t, r.Persister().Connection(context.Background()).Find(&actual, signature)) - require.Equal(t, true, actual.Active) - require.NoError(t, store.RevokeRefreshTokenMaybeGracePeriod(s.t1, request.ID, signature)) - require.NoError(t, r.Persister().Connection(context.Background()).Find(&actual, signature)) - require.Equal(t, false, actual.Active) + accessT1 := persistencesql.OAuth2RequestSQL{Table: "access"} + assert.NoError(t, r.Persister().Connection(s.t1).Where("signature = ?", x.SignatureHash(accessSignatureT1)).First(&accessT1)) + require.Equal(t, true, accessT2.Active) + + // Rotate token T1 + require.NoError(t, r.Persister().RotateRefreshToken(s.t1, request.ID, signatureT1)) + { + refreshT1 := persistencesql.OAuth2RequestSQL{Table: "refresh"} + require.NoError(t, r.Persister().Connection(s.t1).Where("signature = ?", signatureT1).First(&refreshT1)) + require.Equal(t, false, refreshT1.Active) + + accessT1 := persistencesql.OAuth2RequestSQL{Table: "access"} + require.ErrorIs(t, r.Persister().Connection(s.t1).Where("signature = ?", x.SignatureHash(accessSignatureT1)).First(&accessT1), sql.ErrNoRows) + + refreshT2 := persistencesql.OAuth2RequestSQL{Table: "refresh"} + require.NoError(t, r.Persister().Connection(s.t2).Where("signature = ?", signatureT2).First(&refreshT2)) + require.Equal(t, true, refreshT2.Active) + + accessT2 := persistencesql.OAuth2RequestSQL{Table: "access"} + require.NoError(t, r.Persister().Connection(s.t2).Where("signature = ?", x.SignatureHash(accessSignatureT2)).First(&accessT2)) + require.Equal(t, true, accessT2.Active) + } + + require.NoError(t, r.Persister().RotateRefreshToken(s.t2, request.ID, signatureT2)) + { + refreshT2 := persistencesql.OAuth2RequestSQL{Table: "refresh"} + require.NoError(t, r.Persister().Connection(s.t2).Where("signature = ?", signatureT2).First(&refreshT2)) + require.Equal(t, false, refreshT2.Active) + + accessT2 := persistencesql.OAuth2RequestSQL{Table: "access"} + require.ErrorIs(t, r.Persister().Connection(s.t2).Where("signature = ?", x.SignatureHash(accessSignatureT2)).First(&accessT2), sql.ErrNoRows) + require.Equal(t, false, accessT2.Active) + } + }) + + t.Run("without access signature", func(t *testing.T) { + clientID := uuid.Must(uuid.NewV4()).String() + require.NoError(t, r.Persister().CreateClient(s.t1, &client.Client{ID: clientID})) + + request1 := fosite.NewRequest() + request1.Client = &fosite.DefaultClient{ID: clientID} + request1.Session = &oauth2.Session{DefaultSession: &openid.DefaultSession{Subject: "sub"}} + + signature := uuid.Must(uuid.NewV4()).String() + require.NoError(t, r.Persister().CreateRefreshTokenSession(s.t1, signature, "", request1)) + + accessSignature1 := uuid.Must(uuid.NewV4()).String() + require.NoError(t, r.Persister().CreateAccessTokenSession(s.t1, accessSignature1, request1)) + + accessSignature2 := uuid.Must(uuid.NewV4()).String() + require.NoError(t, r.Persister().CreateAccessTokenSession(s.t1, accessSignature2, request1)) + + require.NoError(t, r.Persister().RotateRefreshToken(s.t1, request1.ID, signature)) + { + accessT1 := persistencesql.OAuth2RequestSQL{Table: "access"} + require.ErrorIs(t, r.Persister().Connection(s.t1).Where("signature = ?", x.SignatureHash(accessSignature1)).First(&accessT1), sql.ErrNoRows) + + refresh := persistencesql.OAuth2RequestSQL{Table: "refresh"} + require.NoError(t, r.Persister().Connection(s.t1).Where("signature = ?", signature).First(&refresh)) + require.Equal(t, false, refresh.Active) + + accessT2 := persistencesql.OAuth2RequestSQL{Table: "access"} + require.ErrorIs(t, r.Persister().Connection(s.t1).Where("signature = ?", x.SignatureHash(accessSignature2)).First(&accessT2), sql.ErrNoRows) + } + }) }) } } diff --git a/persistence/sql/persister_nonce_test.go b/persistence/sql/persister_nonce_test.go index 1de7eda543a..933af0a9a7a 100644 --- a/persistence/sql/persister_nonce_test.go +++ b/persistence/sql/persister_nonce_test.go @@ -8,18 +8,19 @@ import ( "testing" "time" + "github.com/ory/hydra/v2/internal/testhelpers" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/ory/fosite" - "github.com/ory/hydra/v2/internal" "github.com/ory/x/contextx" "github.com/ory/x/randx" ) func TestPersister_Nonce(t *testing.T) { ctx := context.Background() - p := internal.NewMockedRegistry(t, new(contextx.Default)).Persister() + p := testhelpers.NewMockedRegistry(t, new(contextx.Default)).Persister() accessToken := randx.MustString(100, randx.AlphaNum) anotherToken := randx.MustString(100, randx.AlphaNum) diff --git a/persistence/sql/persister_oauth2.go b/persistence/sql/persister_oauth2.go index 80246afed72..7595d3b58c4 100644 --- a/persistence/sql/persister_oauth2.go +++ b/persistence/sql/persister_oauth2.go @@ -14,23 +14,22 @@ import ( "strings" "time" - "github.com/ory/hydra/v2/x" - - "github.com/ory/x/sqlxx" - - "go.opentelemetry.io/otel/trace" - "github.com/gofrs/uuid" "github.com/pkg/errors" "github.com/tidwall/gjson" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "github.com/ory/fosite" "github.com/ory/fosite/storage" "github.com/ory/hydra/v2/oauth2" + "github.com/ory/hydra/v2/x" "github.com/ory/hydra/v2/x/events" + "github.com/ory/x/dbal" "github.com/ory/x/errorsx" "github.com/ory/x/otelx" "github.com/ory/x/sqlcon" + "github.com/ory/x/sqlxx" "github.com/ory/x/stringsx" ) @@ -60,7 +59,8 @@ type ( } OAuth2RefreshTable struct { OAuth2RequestSQL - FirstUsedAt sql.NullTime `db:"first_used_at"` + FirstUsedAt sql.NullTime `db:"first_used_at"` + AccessTokenSignature sql.NullString `db:"access_token_signature"` } ) @@ -254,7 +254,7 @@ func (p *Persister) createSession(ctx context.Context, signature string, request } if err = sqlcon.HandleError(p.CreateWithNetwork(ctx, req)); errors.Is(err, sqlcon.ErrConcurrentUpdate) { - return errors.Wrap(fosite.ErrSerializationFailure, err.Error()) + return fosite.ErrSerializationFailure.WithWrap(err) } else if err != nil { return err } @@ -293,7 +293,7 @@ func (p *Persister) deleteSessionBySignature(ctx context.Context, signature stri return errorsx.WithStack(fosite.ErrNotFound) } if errors.Is(err, sqlcon.ErrConcurrentUpdate) { - return errors.Wrap(fosite.ErrSerializationFailure, err.Error()) + return fosite.ErrSerializationFailure.WithWrap(err) } return err } @@ -310,7 +310,7 @@ func (p *Persister) deleteSessionByRequestID(ctx context.Context, id string, tab } if err := sqlcon.HandleError(err); err != nil { if errors.Is(err, sqlcon.ErrConcurrentUpdate) { - return errors.Wrap(fosite.ErrSerializationFailure, err.Error()) + return fosite.ErrSerializationFailure.WithWrap(err) } if strings.Contains(err.Error(), "Error 1213") { // InnoDB Deadlock? return errors.Wrap(fosite.ErrSerializationFailure, err.Error()) @@ -432,7 +432,7 @@ func (p *Persister) DeleteAccessTokenSession(ctx context.Context, signature stri } } if errors.Is(err, sqlcon.ErrConcurrentUpdate) { - return errors.Wrap(fosite.ErrSerializationFailure, err.Error()) + return fosite.ErrSerializationFailure.WithWrap(err) } return err } @@ -451,13 +451,36 @@ func toEventOptions(requester fosite.Requester) []trace.EventOption { } } -func (p *Persister) CreateRefreshTokenSession(ctx context.Context, signature string, requester fosite.Requester) (err error) { +func (p *Persister) CreateRefreshTokenSession(ctx context.Context, signature string, accessTokenSignature string, requester fosite.Requester) (err error) { ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.CreateRefreshTokenSession", trace.WithAttributes(events.RefreshTokenSignature(signature)), ) defer otelx.End(span, &err) events.Trace(ctx, events.RefreshTokenIssued, toEventOptions(requester)...) - return p.createSession(ctx, signature, requester, sqlTableRefresh, requester.GetSession().GetExpiresAt(fosite.RefreshToken).UTC()) + + req, err := p.sqlSchemaFromRequest(ctx, signature, requester, sqlTableRefresh, requester.GetSession().GetExpiresAt(fosite.RefreshToken).UTC()) + if err != nil { + return err + } + + var sig sql.NullString + if len(accessTokenSignature) > 0 { + sig = sql.NullString{ + Valid: true, + String: x.SignatureHash(accessTokenSignature), + } + } + + if err = sqlcon.HandleError(p.CreateWithNetwork(ctx, &OAuth2RefreshTable{ + OAuth2RequestSQL: *req, + AccessTokenSignature: sig, + })); errors.Is(err, sqlcon.ErrConcurrentUpdate) { + return fosite.ErrSerializationFailure.WithWrap(err) + } else if err != nil { + return err + } + + return nil } func (p *Persister) GetRefreshTokenSession(ctx context.Context, signature string, session fosite.Session) (request fosite.Requester, err error) { @@ -466,30 +489,27 @@ func (p *Persister) GetRefreshTokenSession(ctx context.Context, signature string ) defer otelx.End(span, &err) - r := OAuth2RefreshTable{OAuth2RequestSQL: OAuth2RequestSQL{Table: sqlTableRefresh}} - err = p.QueryWithNetwork(ctx).Where("signature = ?", signature).First(&r) - if errors.Is(err, sql.ErrNoRows) { + var row OAuth2RefreshTable + if err := p.QueryWithNetwork(ctx).Where("signature = ?", signature).First(&row); errors.Is(err, sql.ErrNoRows) { return nil, errorsx.WithStack(fosite.ErrNotFound) } else if err != nil { return nil, sqlcon.HandleError(err) } - fositeRequest, err := r.toRequest(ctx, session, p) - if err != nil { - return nil, err - } - - if r.Active { - return fositeRequest, nil + gracePeriod := p.r.Config().RefreshTokenRotationGracePeriod(ctx) + if row.Active { + // Token is active + return row.toRequest(ctx, session, p) + } else if gracePeriod > 0 && + row.FirstUsedAt.Valid && + row.FirstUsedAt.Time.Add(gracePeriod).After(time.Now()) { + // We return the request as is, which indicates that the token is active (because we are in the grace period still). + return row.toRequest(ctx, session, p) } - if gracePeriod := p.r.Config().RefreshTokenRotationGracePeriod(ctx); gracePeriod > 0 && r.FirstUsedAt.Valid { - if r.FirstUsedAt.Time.Add(gracePeriod).Before(time.Now()) { - return fositeRequest, errors.WithStack(fosite.ErrInactiveToken) - } - - r.Active = true // We set active to true because we are in the grace period. - return r.toRequest(ctx, session, p) // And re-generate the request + fositeRequest, err := row.toRequest(ctx, session, p) + if err != nil { + return nil, err } return fositeRequest, errors.WithStack(fosite.ErrInactiveToken) @@ -547,23 +567,7 @@ func (p *Persister) RevokeRefreshToken(ctx context.Context, id string) (err erro trace.WithAttributes(events.ConsentRequestID(id)), ) defer otelx.End(span, &err) - return p.deactivateSessionByRequestID(ctx, id, sqlTableRefresh) -} - -func (p *Persister) RevokeRefreshTokenMaybeGracePeriod(ctx context.Context, id string, _ string) (err error) { - ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.RevokeRefreshTokenMaybeGracePeriod") - defer otelx.End(span, &err) - - /* #nosec G201 table is static */ - return sqlcon.HandleError( - p.Connection(ctx). - RawQuery( - fmt.Sprintf("UPDATE %s SET active=false, first_used_at = CURRENT_TIMESTAMP WHERE request_id=? AND nid = ? AND active", OAuth2RequestSQL{Table: sqlTableRefresh}.TableName()), - id, - p.NetworkID(ctx), - ). - Exec(), - ) + return p.deleteSessionByRequestID(ctx, id, sqlTableRefresh) } func (p *Persister) RevokeAccessToken(ctx context.Context, id string) (err error) { @@ -630,3 +634,123 @@ func (p *Persister) DeleteAccessTokens(ctx context.Context, clientID string) (er p.QueryWithNetwork(ctx).Where("client_id=?", clientID).Delete(&OAuth2RequestSQL{Table: sqlTableAccess}), ) } + +func handleRetryError(err error) error { + if err == nil { + return nil + } + + if errors.Is(err, sqlcon.ErrConcurrentUpdate) { + return fosite.ErrSerializationFailure.WithWrap(err) + } + if strings.Contains(err.Error(), "Error 1213") { // InnoDB Deadlock + return errors.Wrap(fosite.ErrSerializationFailure, err.Error()) + } + return err +} + +// strictRefreshRotation implements the strict refresh token rotation strategy. In strict rotation, we disable all +// refresh and access tokens associated with a request ID and subsequently create the only valid, new token pair. +func (p *Persister) strictRefreshRotation(ctx context.Context, requestID string) (err error) { + ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.strictRefreshRotation", + trace.WithAttributes( + attribute.String("request_id", requestID), + attribute.String("network_id", p.NetworkID(ctx).String()))) + defer otelx.End(span, &err) + + c := p.Connection(ctx) + + // In strict rotation we only have one token chain for every request. Therefore, we remove all + // access tokens associated with the request ID. + if err := p.deleteSessionByRequestID(ctx, requestID, sqlTableAccess); err != nil { + return err + } + + // The same applies to refresh tokens in strict mode. We disable all old refresh tokens when rotating. + count, err := c.RawQuery( + "UPDATE hydra_oauth2_refresh SET active=false WHERE request_id=? AND nid = ? AND active", + requestID, + p.NetworkID(ctx), + ).ExecWithCount() + if err != nil { + return sqlcon.HandleError(err) + } else if count == 0 { + return errorsx.WithStack(fosite.ErrNotFound) + } + + return nil +} + +func (p *Persister) gracefulRefreshRotation(ctx context.Context, requestID string, refreshSignature string, period time.Duration) (err error) { + ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.gracefulRefreshRotation", + trace.WithAttributes( + attribute.String("request_id", requestID), + attribute.String("network_id", p.NetworkID(ctx).String()))) + defer otelx.End(span, &err) + + c := p.Connection(ctx) + now := time.Now().UTC().Round(time.Millisecond) + + var accessTokenSignature sql.NullString + if p.conn.Dialect.Name() == dbal.DriverMySQL { + // MySQL does not support returning values from an update query, so we need to do two queries. + var tokenToRevoke OAuth2RefreshTable + if err := c. + Select("access_token_signature"). + // Filtering by "active" status would break graceful token rotation. We know and trust (with tests) + // that Fosite is dealing with the refresh token reuse detection business logic without + // relying on the active filter her. + Where("signature=? AND nid = ?", refreshSignature, p.NetworkID(ctx)). + First(&tokenToRevoke); err != nil { + return sqlcon.HandleError(err) + } + + if count, err := c.RawQuery( + // Signature is the primary key so no limit needed. We only update first_used_at if it is not set yet (otherwise + // we would "refresh" the grace period again and again, and the refresh token would never "expire"). + `UPDATE hydra_oauth2_refresh SET active=false, first_used_at = COALESCE(first_used_at, ?) WHERE signature=? AND nid = ?`, + now, refreshSignature, p.NetworkID(ctx), + ).ExecWithCount(); err != nil { + return sqlcon.HandleError(err) + } else if count == 0 { + return errorsx.WithStack(fosite.ErrNotFound) + } + + accessTokenSignature = tokenToRevoke.AccessTokenSignature + } else { + var tokenToRevoke OAuth2RefreshTable + if err := c.RawQuery( + // Same query like in the MySQL case, but we can return the access token signature directly. + `UPDATE hydra_oauth2_refresh SET active=false, first_used_at = COALESCE(first_used_at, ?) WHERE signature=? AND nid = ? RETURNING access_token_signature`, + now, refreshSignature, p.NetworkID(ctx), + ).First(&tokenToRevoke); err != nil { + return sqlcon.HandleError(err) + } + + accessTokenSignature = tokenToRevoke.AccessTokenSignature + } + + if !accessTokenSignature.Valid { + // If the access token is not found, we fall back to deleting all access tokens associated with the request ID. + if err := p.deleteSessionByRequestID(ctx, requestID, sqlTableAccess); err != nil { + return err + } + return nil + } + + // We have the signature and we will only remove that specific access token as part of the rotation. + return p.deleteSessionBySignature(ctx, accessTokenSignature.String, sqlTableAccess) +} + +func (p *Persister) RotateRefreshToken(ctx context.Context, requestID string, refreshTokenSignature string) (err error) { + ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.RotateRefreshToken") + defer otelx.End(span, &err) + + // If we end up here, we have a valid refresh token and can proceed with the rotation. + gracePeriod := p.r.Config().RefreshTokenRotationGracePeriod(ctx) + if gracePeriod > 0 { + return handleRetryError(p.gracefulRefreshRotation(ctx, requestID, refreshTokenSignature, gracePeriod)) + } + + return handleRetryError(p.strictRefreshRotation(ctx, requestID)) +} diff --git a/persistence/sql/persister_test.go b/persistence/sql/persister_test.go index a4818a3e69d..b4c88ef01c3 100644 --- a/persistence/sql/persister_test.go +++ b/persistence/sql/persister_test.go @@ -28,7 +28,6 @@ import ( "github.com/ory/hydra/v2/jwk" "github.com/ory/hydra/v2/driver" - "github.com/ory/hydra/v2/internal" ) func init() { @@ -120,11 +119,11 @@ func testRegistry(t *testing.T, ctx context.Context, k string, t1 driver.Registr func TestManagersNextGen(t *testing.T) { regs := map[string]driver.Registry{ - "memory": internal.NewRegistrySQLFromURL(t, dbal.NewSQLiteTestDatabase(t), true, &contextx.Default{}), + "memory": testhelpers.NewRegistrySQLFromURL(t, dbal.NewSQLiteTestDatabase(t), true, &contextx.Default{}), } if !testing.Short() { - regs["postgres"], regs["mysql"], regs["cockroach"], _ = internal.ConnectDatabases(t, true, &contextx.Default{}) + regs["postgres"], regs["mysql"], regs["cockroach"], _ = testhelpers.ConnectDatabases(t, true, &contextx.Default{}) } ctx := context.Background() @@ -153,16 +152,16 @@ func TestManagersNextGen(t *testing.T) { func TestManagers(t *testing.T) { ctx := context.TODO() t1registries := map[string]driver.Registry{ - "memory": internal.NewRegistrySQLFromURL(t, dbal.NewSQLiteTestDatabase(t), true, &contextx.Default{}), + "memory": testhelpers.NewRegistrySQLFromURL(t, dbal.NewSQLiteTestDatabase(t), true, &contextx.Default{}), } t2registries := map[string]driver.Registry{ - "memory": internal.NewRegistrySQLFromURL(t, dbal.NewSQLiteTestDatabase(t), false, &contextx.Default{}), + "memory": testhelpers.NewRegistrySQLFromURL(t, dbal.NewSQLiteTestDatabase(t), false, &contextx.Default{}), } if !testing.Short() { - t2registries["postgres"], t2registries["mysql"], t2registries["cockroach"], _ = internal.ConnectDatabases(t, false, &contextx.Default{}) - t1registries["postgres"], t1registries["mysql"], t1registries["cockroach"], _ = internal.ConnectDatabases(t, true, &contextx.Default{}) + t2registries["postgres"], t2registries["mysql"], t2registries["cockroach"], _ = testhelpers.ConnectDatabases(t, false, &contextx.Default{}) + t1registries["postgres"], t1registries["mysql"], t1registries["cockroach"], _ = testhelpers.ConnectDatabases(t, true, &contextx.Default{}) } network1NID, _ := uuid.NewV4() diff --git a/spec/config.json b/spec/config.json index 72f81534c66..effd1cc866d 100644 --- a/spec/config.json +++ b/spec/config.json @@ -1071,9 +1071,9 @@ "refresh_token": { "type": "object", "properties": { - "grace_period": { + "rotation_grace_period": { "title": "Refresh Token Rotation Grace Period", - "description": "Configures how long a Refresh Token remains valid after it has been used. The maximum value is one hour.", + "description": "Configures how long a Refresh Token remains valid after it has been used. The maximum value is 5 minutes.", "default": "0s", "allOf": [ { diff --git a/x/oauth2cors/cors_test.go b/x/oauth2cors/cors_test.go index d450fe308ab..dee215eae77 100644 --- a/x/oauth2cors/cors_test.go +++ b/x/oauth2cors/cors_test.go @@ -14,6 +14,8 @@ import ( "testing" "time" + "github.com/ory/hydra/v2/internal/testhelpers" + "github.com/ory/hydra/v2/driver" "github.com/ory/x/contextx" @@ -24,13 +26,12 @@ import ( "github.com/ory/fosite" "github.com/ory/hydra/v2/client" - "github.com/ory/hydra/v2/internal" "github.com/ory/hydra/v2/oauth2" ) func TestOAuth2AwareCORSMiddleware(t *testing.T) { ctx := context.Background() - r := internal.NewRegistryMemory(t, internal.NewConfigurationWithDefaults(), &contextx.Default{}) + r := testhelpers.NewRegistryMemory(t, testhelpers.NewConfigurationWithDefaults(), &contextx.Default{}) token, signature, _ := r.OAuth2HMACStrategy().GenerateAccessToken(ctx, nil) for k, tc := range []struct { @@ -275,7 +276,7 @@ func TestOAuth2AwareCORSMiddleware(t *testing.T) { }, } { t.Run(fmt.Sprintf("case=%d/description=%s", k, tc.d), func(t *testing.T) { - r.WithConfig(internal.NewConfigurationWithDefaults()) + r.WithConfig(testhelpers.NewConfigurationWithDefaults()) if tc.prep != nil { tc.prep(t, r) diff --git a/x/tls_termination_test.go b/x/tls_termination_test.go index bdb5581ce91..0c7be56f549 100644 --- a/x/tls_termination_test.go +++ b/x/tls_termination_test.go @@ -10,10 +10,11 @@ import ( "net/url" "testing" + "github.com/ory/hydra/v2/internal/testhelpers" + "github.com/stretchr/testify/assert" "github.com/ory/hydra/v2/driver/config" - "github.com/ory/hydra/v2/internal" . "github.com/ory/hydra/v2/x" "github.com/ory/x/contextx" ) @@ -27,8 +28,8 @@ func noopHandler(w http.ResponseWriter, r *http.Request) { } func TestDoesRequestSatisfyTermination(t *testing.T) { - c := internal.NewConfigurationWithDefaultsAndHTTPS() - r := internal.NewRegistryMemory(t, c, &contextx.Default{}) + c := testhelpers.NewConfigurationWithDefaultsAndHTTPS() + r := testhelpers.NewRegistryMemory(t, c, &contextx.Default{}) t.Run("case=tls-termination-disabled", func(t *testing.T) { c.MustSet(context.Background(), config.KeyTLSAllowTerminationFrom, "") @@ -178,7 +179,7 @@ func TestDoesRequestSatisfyTermination(t *testing.T) { // test: in case http is forced request should be accepted t.Run("case=forced-http", func(t *testing.T) { - c := internal.NewConfigurationWithDefaults() + c := testhelpers.NewConfigurationWithDefaults() res := httptest.NewRecorder() RejectInsecureRequests(r, c.TLS(context.Background(), config.PublicInterface))(res, &http.Request{Header: http.Header{}, URL: new(url.URL)}, noopHandler) assert.EqualValues(t, http.StatusNoContent, res.Code)