diff --git a/oauth2/fosite_store_test.go b/oauth2/fosite_store_test.go index 2a48a52f8e7..292988b77c4 100644 --- a/oauth2/fosite_store_test.go +++ b/oauth2/fosite_store_test.go @@ -16,7 +16,6 @@ import ( "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" ) @@ -72,14 +71,11 @@ func TestManagers(t *testing.T) { 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. - 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) + for k, _ := range registries { + reg := internal.NewRegistrySQLFromURL(t, registries[k].Config().DSN(), true, &contextx.Default{}) + reg.Config().MustSet(ctx, config.KeyEncryptSessionData, tc.enableSessionEncrypted) + TestHelperRunner(t, reg, k) } }) - } } diff --git a/oauth2/oauth2_auth_code_test.go b/oauth2/oauth2_auth_code_test.go index 0d89e14ac9b..10e629c6cba 100644 --- a/oauth2/oauth2_auth_code_test.go +++ b/oauth2/oauth2_auth_code_test.go @@ -10,6 +10,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/ory/hydra/v2/jwk" "io" "net/http" "net/http/httptest" @@ -62,6 +63,95 @@ type clientCreator interface { CreateClient(context.Context, *client.Client) error } +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() + + q := resp.Request.URL.Query() + require.EqualValues(t, state, q.Get("state")) + return q.Get("code"), resp +} + +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) + + 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 + } + } + + 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 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) + + 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) + } + + 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) + } +} + // TestAuthCodeWithDefaultStrategy runs proper integration tests against in-memory and database connectors, specifically // we test: // @@ -87,94 +177,6 @@ func TestAuthCodeWithDefaultStrategy(t *testing.T) { 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) - } - - 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 - } - - 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 - } - } - - 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) - } - } - - 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) - } - - 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) @@ -266,8 +268,8 @@ func TestAuthCodeWithDefaultStrategy(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), + acceptLoginHandler(t, c, adminClient, reg, subject, nil), + acceptConsentHandler(t, c, adminClient, reg, subject, nil), ) code, _ := getAuthorizeCode(t, conf, nil, oauth2.SetAuthURLParam("nonce", nonce)) @@ -342,8 +344,8 @@ func TestAuthCodeWithDefaultStrategy(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), + acceptLoginHandler(t, c, adminClient, reg, subject, nil), + acceptConsentHandler(t, c, adminClient, reg, subject, nil), ) issueTokens := func(t *testing.T) *oauth2.Token { @@ -378,7 +380,7 @@ func TestAuthCodeWithDefaultStrategy(t *testing.T) { return refreshedToken } - t.Run("followup=successfully perform refresh token flow", func(t *testing.T) { + t.Run("followup=graceful token refresh with reuse detection", func(t *testing.T) { start := time.Now() token := issueTokens(t) @@ -411,7 +413,7 @@ func TestAuthCodeWithDefaultStrategy(t *testing.T) { }) }) - t.Run("followup=successfully perform refresh token flow", func(t *testing.T) { + t.Run("followup=graceful token refresh with reuse detection with consent revocation", func(t *testing.T) { start := time.Now() token := issueTokens(t) @@ -447,7 +449,57 @@ func TestAuthCodeWithDefaultStrategy(t *testing.T) { }) }) - t.Run("followup=graceful refresh tokens are all refreshed", func(t *testing.T) { + t.Run("followup=graceful token refresh can handle concurrent refreshing", func(t *testing.T) { + start := time.Now() + + token := issueTokens(t) + var first, second *oauth2.Token + var wg sync.WaitGroup + refreshes := make([]*oauth2.Token, 5) + for k := range refreshes { + wg.Add(1) + go func(k int) { + defer wg.Done() + t.Logf("Refreshing token %d", k) + refreshes[k] = refreshTokens(t, token) + }(k) + } + + wg.Wait() + for k, refresh := range refreshes { + t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { + iat := time.Now() + introspectAccessToken(t, conf, refresh, subject) + assertJWTAccessToken(t, strategy, conf, refresh, subject, iat.Add(reg.Config().GetAccessTokenLifespan(ctx)), `["hydra","offline","openid"]`) + assertIDToken(t, refresh, conf, subject, nonce, iat.Add(reg.Config().GetIDTokenLifespan(ctx))) + assertRefreshToken(t, refresh, conf, iat.Add(reg.Config().GetRefreshTokenLifespan(ctx))) + }) + } + + // 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) + require.NoError(t, err) + + _, err = conf.TokenSource(context.Background(), token).Token() + assert.Error(t, err) + + i := testhelpers.IntrospectToken(t, conf, first.AccessToken, adminTS) + assert.False(t, i.Get("active").Bool(), "%s", i) + + i = testhelpers.IntrospectToken(t, conf, second.AccessToken, adminTS) + assert.False(t, i.Get("active").Bool(), "%s", i) + + i = testhelpers.IntrospectToken(t, conf, first.RefreshToken, 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=graceful refresh tokens with multiple nested branches belong to the same request", func(t *testing.T) { start := time.Now() token := issueTokens(t) var a1Refresh, b1Refresh, a2RefreshA, a2RefreshB, b2RefreshA, b2RefreshB *oauth2.Token @@ -698,8 +750,8 @@ func TestAuthCodeWithDefaultStrategy(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), + acceptLoginHandler(t, c, adminClient, reg, subject, nil), + acceptConsentHandler(t, c, adminClient, reg, subject, nil), ) withWrongClientAfterLogin := &http.Client{ @@ -813,13 +865,13 @@ func TestAuthCodeWithDefaultStrategy(t *testing.T) { t.Run("case=perform flow with prompt=registration", func(t *testing.T) { c, conf := newOAuth2Client(t, reg, testhelpers.NewCallbackURL(t, "callback", testhelpers.HTTPServerNotImplementedHandler)) - regUI := httptest.NewServer(acceptLoginHandler(t, c, subject, nil)) + regUI := httptest.NewServer(acceptLoginHandler(t, c, adminClient, reg, subject, nil)) t.Cleanup(regUI.Close) reg.Config().MustSet(ctx, config.KeyRegistrationURL, regUI.URL) testhelpers.NewLoginConsentUI(t, reg.Config(), nil, - acceptConsentHandler(t, c, subject, nil)) + acceptConsentHandler(t, c, adminClient, reg, subject, nil)) code, _ := getAuthorizeCode(t, conf, nil, oauth2.SetAuthURLParam("prompt", "registration"), @@ -836,12 +888,12 @@ func TestAuthCodeWithDefaultStrategy(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 { + 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, subject, func(r *hydra.OAuth2ConsentRequest) { + acceptConsentHandler(t, c, adminClient, reg, subject, func(r *hydra.OAuth2ConsentRequest) { assert.False(t, *r.Skip) assert.EqualValues(t, []string{expectAud}, r.RequestedAccessTokenAudience) })) @@ -865,8 +917,8 @@ func TestAuthCodeWithDefaultStrategy(t *testing.T) { 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), + acceptLoginHandler(t, c, adminClient, reg, subject, nil), + acceptConsentHandler(t, c, adminClient, reg, subject, nil), ) code, _ := getAuthorizeCode(t, conf, nil, oauth2.SetAuthURLParam("nonce", nonce)) @@ -967,8 +1019,8 @@ func TestAuthCodeWithDefaultStrategy(t *testing.T) { 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), + acceptLoginHandler(t, c, adminClient, reg, subject, nil), + acceptConsentHandler(t, c, adminClient, reg, subject, nil), ) oc := testhelpers.NewEmptyJarClient(t) @@ -984,12 +1036,12 @@ func TestAuthCodeWithDefaultStrategy(t *testing.T) { // Reset UI to check for skip values 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.True(t, r.Skip) require.EqualValues(t, subject, r.Subject) return nil }), - acceptConsentHandler(t, c, subject, func(r *hydra.OAuth2ConsentRequest) { + acceptConsentHandler(t, c, adminClient, reg, subject, func(r *hydra.OAuth2ConsentRequest) { require.True(t, *r.Skip) require.EqualValues(t, subject, *r.Subject) }), @@ -1038,12 +1090,12 @@ func TestAuthCodeWithDefaultStrategy(t *testing.T) { t.Run("followup=passes and resets skip when prompt=login", func(t *testing.T) { 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) { + acceptConsentHandler(t, c, adminClient, reg, subject, func(r *hydra.OAuth2ConsentRequest) { require.True(t, *r.Skip) require.EqualValues(t, subject, *r.Subject) }), @@ -1065,8 +1117,8 @@ func TestAuthCodeWithDefaultStrategy(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, subject, nil), - acceptConsentHandler(t, c, subject, nil), + acceptLoginHandler(t, c, adminClient, reg, subject, nil), + acceptConsentHandler(t, c, adminClient, reg, subject, nil), ) oc := testhelpers.NewEmptyJarClient(t) @@ -1079,12 +1131,12 @@ func TestAuthCodeWithDefaultStrategy(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, nil), + acceptConsentHandler(t, c, adminClient, reg, subject, nil), ) oc := testhelpers.NewEmptyJarClient(t) @@ -1103,11 +1155,11 @@ func TestAuthCodeWithDefaultStrategy(t *testing.T) { 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 { + acceptLoginHandler(t, c, adminClient, reg, subject, func(r *hydra.OAuth2LoginRequest) *hydra.AcceptOAuth2LoginRequest { time.Sleep(time.Second * 2) return nil }), - acceptConsentHandler(t, c, subject, nil), + acceptConsentHandler(t, c, adminClient, reg, subject, nil), ) code, _ := getAuthorizeCode(t, conf, nil) @@ -1117,8 +1169,8 @@ func TestAuthCodeWithDefaultStrategy(t *testing.T) { 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), + acceptLoginHandler(t, c, adminClient, reg, subject, nil), + acceptConsentHandler(t, c, adminClient, reg, subject, nil), ) code, _ := getAuthorizeCode(t, conf, nil) @@ -1203,12 +1255,12 @@ func TestAuthCodeWithDefaultStrategy(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 { + 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, subject, func(r *hydra.OAuth2ConsentRequest) { + acceptConsentHandler(t, c, adminClient, reg, subject, func(r *hydra.OAuth2ConsentRequest) { assert.False(t, *r.Skip) assert.EqualValues(t, []string{expectAud}, r.RequestedAccessTokenAudience) })) @@ -1252,12 +1304,12 @@ func TestAuthCodeWithDefaultStrategy(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 { + 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, subject, func(r *hydra.OAuth2ConsentRequest) { + acceptConsentHandler(t, c, adminClient, reg, subject, func(r *hydra.OAuth2ConsentRequest) { assert.False(t, *r.Skip) assert.EqualValues(t, []string{expectAud}, r.RequestedAccessTokenAudience) })) @@ -1292,12 +1344,12 @@ func TestAuthCodeWithDefaultStrategy(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 { + 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, subject, func(r *hydra.OAuth2ConsentRequest) { + acceptConsentHandler(t, c, adminClient, reg, subject, func(r *hydra.OAuth2ConsentRequest) { assert.False(t, *r.Skip) assert.EqualValues(t, []string{expectAud}, r.RequestedAccessTokenAudience) })) @@ -1332,12 +1384,12 @@ func TestAuthCodeWithDefaultStrategy(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 { + 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, subject, func(r *hydra.OAuth2ConsentRequest) { + acceptConsentHandler(t, c, adminClient, reg, subject, func(r *hydra.OAuth2ConsentRequest) { assert.False(t, *r.Skip) assert.EqualValues(t, []string{expectAud}, r.RequestedAccessTokenAudience) })) @@ -1483,21 +1535,23 @@ func createVCProofJWT(t *testing.T, pubKey *jose.JSONWebKey, privKey any, nonce // - [x] should pass with prompt=login when authentication time is recent // - [x] should fail with prompt=login when authentication time is in the past 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() + setupRegistries(t) + + for k := range registries { + t.Run("registry="+k, func(t *testing.T) { + ctx := context.Background() + reg := internal.NewRegistrySQLFromURL(t, registries[k].Config().DSN(), true, &contextx.Default{}) + + require.NoError(t, jwk.EnsureAsymmetricKeypairExists(ctx, reg, string(jose.ES256), x.OpenIDConnectKeyName)) + require.NoError(t, jwk.EnsureAsymmetricKeypairExists(ctx, reg, string(jose.ES256), x.OAuth2JWTKeyName)) + + conf := reg.Config() 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) - consentStrategy := &consentMock{} router := x.NewRouterPublic() ts := httptest.NewServer(router) - defer ts.Close() + t.Cleanup(ts.Close) reg.WithConsentStrategy(consentStrategy) handler := reg.OAuth2Handler() @@ -1511,7 +1565,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"}, @@ -1531,524 +1585,531 @@ func TestAuthCodeWithMockStrategy(t *testing.T) { Scopes: []string{"hydra.*", "offline", "openid"}, } - var code string - for k, tc := range []struct { - cj http.CookieJar - d string - cb func(t *testing.T) httprouter.Handle - authURL string - shouldPassConsentStrategy bool - expectOAuthAuthError bool - expectOAuthTokenError bool - checkExpiry bool - authTime time.Time - requestTime time.Time - assertAccessToken func(*testing.T, string) - }{ - { - d: "should pass request if strategy passes", - authURL: oauthConfig.AuthCodeURL("some-foo-state"), - shouldPassConsentStrategy: true, - checkExpiry: true, - cb: func(t *testing.T) httprouter.Handle { - return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - code = r.URL.Query().Get("code") - require.NotEmpty(t, code) - _, _ = w.Write([]byte(r.URL.Query().Get("code"))) - } - }, - assertAccessToken: func(t *testing.T, token string) { - if strat.d != "jwt" { - return - } - - body, err := x.DecodeSegment(strings.Split(token, ".")[1]) - require.NoError(t, err) - - data := map[string]interface{}{} - require.NoError(t, json.Unmarshal(body, &data)) - - assert.EqualValues(t, "app-client", data["client_id"]) - assert.EqualValues(t, "foo", data["sub"]) - assert.NotEmpty(t, data["iss"]) - assert.NotEmpty(t, data["jti"]) - assert.NotEmpty(t, data["exp"]) - assert.NotEmpty(t, data["iat"]) - assert.NotEmpty(t, data["nbf"]) - assert.EqualValues(t, data["nbf"], data["iat"]) - assert.EqualValues(t, []interface{}{"offline", "openid", "hydra.*"}, data["scp"]) - }, - }, - { - d: "should fail because prompt=none and max_age > auth_time", - authURL: oauthConfig.AuthCodeURL("some-foo-state") + "&prompt=none&max_age=1", - authTime: time.Now().UTC().Add(-time.Minute), - requestTime: time.Now().UTC(), - shouldPassConsentStrategy: true, - cb: func(t *testing.T) httprouter.Handle { - return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - code = r.URL.Query().Get("code") - err := r.URL.Query().Get("error") - require.Empty(t, code) - require.EqualValues(t, fosite.ErrLoginRequired.Error(), err) - } - }, - expectOAuthAuthError: true, - }, - { - d: "should pass because prompt=none and max_age is less than auth_time", - authURL: oauthConfig.AuthCodeURL("some-foo-state") + "&prompt=none&max_age=3600", - authTime: time.Now().UTC().Add(-time.Minute), - requestTime: time.Now().UTC(), - shouldPassConsentStrategy: true, - cb: func(t *testing.T) httprouter.Handle { - return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - code = r.URL.Query().Get("code") - require.NotEmpty(t, code) - _, _ = w.Write([]byte(r.URL.Query().Get("code"))) - } - }, - }, - { - d: "should fail because prompt=none but auth_time suggests recent authentication", - authURL: oauthConfig.AuthCodeURL("some-foo-state") + "&prompt=none", - authTime: time.Now().UTC().Add(-time.Minute), - requestTime: time.Now().UTC().Add(-time.Hour), - shouldPassConsentStrategy: true, - cb: func(t *testing.T) httprouter.Handle { - return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - code = r.URL.Query().Get("code") - err := r.URL.Query().Get("error") - require.Empty(t, code) - require.EqualValues(t, fosite.ErrLoginRequired.Error(), err) - } - }, - expectOAuthAuthError: true, - }, - { - d: "should fail because consent strategy fails", - authURL: oauthConfig.AuthCodeURL("some-foo-state"), - expectOAuthAuthError: true, - shouldPassConsentStrategy: false, - cb: func(t *testing.T) httprouter.Handle { - return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - require.Empty(t, r.URL.Query().Get("code")) - assert.Equal(t, fosite.ErrRequestForbidden.Error(), r.URL.Query().Get("error")) - } - }, - }, - { - d: "should pass with prompt=login when authentication time is recent", - authURL: oauthConfig.AuthCodeURL("some-foo-state") + "&prompt=login", - authTime: time.Now().UTC().Add(-time.Second), - requestTime: time.Now().UTC().Add(-time.Minute), - shouldPassConsentStrategy: true, - cb: func(t *testing.T) httprouter.Handle { - return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - code = r.URL.Query().Get("code") - require.NotEmpty(t, code) - _, _ = w.Write([]byte(r.URL.Query().Get("code"))) - } - }, - }, - { - d: "should fail with prompt=login when authentication time is in the past", - authURL: oauthConfig.AuthCodeURL("some-foo-state") + "&prompt=login", - authTime: time.Now().UTC().Add(-time.Minute), - requestTime: time.Now().UTC(), - expectOAuthAuthError: true, - shouldPassConsentStrategy: true, - cb: func(t *testing.T) httprouter.Handle { - return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - code = r.URL.Query().Get("code") - require.Empty(t, code) - assert.Equal(t, fosite.ErrLoginRequired.Error(), r.URL.Query().Get("error")) - } - }, - }, - } { - t.Run(fmt.Sprintf("case=%d/description=%s", k, tc.d), func(t *testing.T) { - mutex.Lock() - defer mutex.Unlock() - if tc.cb == nil { - tc.cb = noopHandler - } - - consentStrategy.deny = !tc.shouldPassConsentStrategy - consentStrategy.authTime = tc.authTime - consentStrategy.requestTime = tc.requestTime - - cb := tc.cb(t) - callbackHandler = &cb - - req, err := http.NewRequest("GET", tc.authURL, nil) - require.NoError(t, err) - - if tc.cj == nil { - tc.cj = testhelpers.NewEmptyCookieJar(t) - } + for _, strat := range []struct{ d string }{{d: "opaque"}, {d: "jwt"}} { + conf := reg.Config() + conf.MustSet(ctx, config.KeyAccessTokenStrategy, strat.d) + + t.Run("strategy="+strat.d, func(t *testing.T) { + var code string + for k, tc := range []struct { + cj http.CookieJar + d string + cb func(t *testing.T) httprouter.Handle + authURL string + shouldPassConsentStrategy bool + expectOAuthAuthError bool + expectOAuthTokenError bool + checkExpiry bool + authTime time.Time + requestTime time.Time + assertAccessToken func(*testing.T, string) + }{ + { + d: "should pass request if strategy passes", + authURL: oauthConfig.AuthCodeURL("some-foo-state"), + shouldPassConsentStrategy: true, + checkExpiry: true, + cb: func(t *testing.T) httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + code = r.URL.Query().Get("code") + require.NotEmpty(t, code) + _, _ = w.Write([]byte(r.URL.Query().Get("code"))) + } + }, + assertAccessToken: func(t *testing.T, token string) { + if strat.d != "jwt" { + return + } - resp, err := (&http.Client{Jar: tc.cj}).Do(req) - require.NoError(t, err, tc.authURL, ts.URL) - defer resp.Body.Close() + body, err := x.DecodeSegment(strings.Split(token, ".")[1]) + require.NoError(t, err) - if tc.expectOAuthAuthError { - require.Empty(t, code) - return - } + data := map[string]interface{}{} + require.NoError(t, json.Unmarshal(body, &data)) + + assert.EqualValues(t, "app-client", data["client_id"]) + assert.EqualValues(t, "foo", data["sub"]) + assert.NotEmpty(t, data["iss"]) + assert.NotEmpty(t, data["jti"]) + assert.NotEmpty(t, data["exp"]) + assert.NotEmpty(t, data["iat"]) + assert.NotEmpty(t, data["nbf"]) + assert.EqualValues(t, data["nbf"], data["iat"]) + assert.EqualValues(t, []interface{}{"offline", "openid", "hydra.*"}, data["scp"]) + }, + }, + { + d: "should fail because prompt=none and max_age > auth_time", + authURL: oauthConfig.AuthCodeURL("some-foo-state") + "&prompt=none&max_age=1", + authTime: time.Now().UTC().Add(-time.Minute), + requestTime: time.Now().UTC(), + shouldPassConsentStrategy: true, + cb: func(t *testing.T) httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + code = r.URL.Query().Get("code") + err := r.URL.Query().Get("error") + require.Empty(t, code) + require.EqualValues(t, fosite.ErrLoginRequired.Error(), err) + } + }, + expectOAuthAuthError: true, + }, + { + d: "should pass because prompt=none and max_age is less than auth_time", + authURL: oauthConfig.AuthCodeURL("some-foo-state") + "&prompt=none&max_age=3600", + authTime: time.Now().UTC().Add(-time.Minute), + requestTime: time.Now().UTC(), + shouldPassConsentStrategy: true, + cb: func(t *testing.T) httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + code = r.URL.Query().Get("code") + require.NotEmpty(t, code) + _, _ = w.Write([]byte(r.URL.Query().Get("code"))) + } + }, + }, + { + d: "should fail because prompt=none but auth_time suggests recent authentication", + authURL: oauthConfig.AuthCodeURL("some-foo-state") + "&prompt=none", + authTime: time.Now().UTC().Add(-time.Minute), + requestTime: time.Now().UTC().Add(-time.Hour), + shouldPassConsentStrategy: true, + cb: func(t *testing.T) httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + code = r.URL.Query().Get("code") + err := r.URL.Query().Get("error") + require.Empty(t, code) + require.EqualValues(t, fosite.ErrLoginRequired.Error(), err) + } + }, + expectOAuthAuthError: true, + }, + { + d: "should fail because consent strategy fails", + authURL: oauthConfig.AuthCodeURL("some-foo-state"), + expectOAuthAuthError: true, + shouldPassConsentStrategy: false, + cb: func(t *testing.T) httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + require.Empty(t, r.URL.Query().Get("code")) + assert.Equal(t, fosite.ErrRequestForbidden.Error(), r.URL.Query().Get("error")) + } + }, + }, + { + d: "should pass with prompt=login when authentication time is recent", + authURL: oauthConfig.AuthCodeURL("some-foo-state") + "&prompt=login", + authTime: time.Now().UTC().Add(-time.Second), + requestTime: time.Now().UTC().Add(-time.Minute), + shouldPassConsentStrategy: true, + cb: func(t *testing.T) httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + code = r.URL.Query().Get("code") + require.NotEmpty(t, code) + _, _ = w.Write([]byte(r.URL.Query().Get("code"))) + } + }, + }, + { + d: "should fail with prompt=login when authentication time is in the past", + authURL: oauthConfig.AuthCodeURL("some-foo-state") + "&prompt=login", + authTime: time.Now().UTC().Add(-time.Minute), + requestTime: time.Now().UTC(), + expectOAuthAuthError: true, + shouldPassConsentStrategy: true, + cb: func(t *testing.T) httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + code = r.URL.Query().Get("code") + require.Empty(t, code) + assert.Equal(t, fosite.ErrLoginRequired.Error(), r.URL.Query().Get("error")) + } + }, + }, + } { + t.Run(fmt.Sprintf("case=%d/description=%s", k, tc.d), func(t *testing.T) { + mutex.Lock() + defer mutex.Unlock() + if tc.cb == nil { + tc.cb = noopHandler + } - require.NotEmpty(t, code) + consentStrategy.deny = !tc.shouldPassConsentStrategy + consentStrategy.authTime = tc.authTime + consentStrategy.requestTime = tc.requestTime - token, err := oauthConfig.Exchange(context.TODO(), code) - if tc.expectOAuthTokenError { - require.Error(t, err) - return - } + cb := tc.cb(t) + callbackHandler = &cb - require.NoError(t, err, code) - if tc.assertAccessToken != nil { - tc.assertAccessToken(t, token.AccessToken) - } - - t.Run("case=userinfo", func(t *testing.T) { - var makeRequest = func(req *http.Request) *http.Response { - resp, err = http.DefaultClient.Do(req) + req, err := http.NewRequest("GET", tc.authURL, nil) require.NoError(t, err) - return resp - } - - var testSuccess = func(response *http.Response) { - defer resp.Body.Close() - - require.Equal(t, http.StatusOK, resp.StatusCode) - - var claims map[string]interface{} - require.NoError(t, json.NewDecoder(resp.Body).Decode(&claims)) - assert.Equal(t, "foo", claims["sub"]) - } - - req, err = http.NewRequest("GET", ts.URL+"/userinfo", nil) - req.Header.Add("Authorization", "bearer "+token.AccessToken) - testSuccess(makeRequest(req)) - - req, err = http.NewRequest("POST", ts.URL+"/userinfo", nil) - req.Header.Add("Authorization", "bearer "+token.AccessToken) - testSuccess(makeRequest(req)) - - req, err = http.NewRequest("POST", ts.URL+"/userinfo", bytes.NewBuffer([]byte("access_token="+token.AccessToken))) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - testSuccess(makeRequest(req)) - req, err = http.NewRequest("GET", ts.URL+"/userinfo", nil) - req.Header.Add("Authorization", "bearer asdfg") - resp := makeRequest(req) - require.Equal(t, http.StatusUnauthorized, resp.StatusCode) - }) + if tc.cj == nil { + tc.cj = testhelpers.NewEmptyCookieJar(t) + } - res, err := testRefresh(t, token, ts.URL, tc.checkExpiry) - require.NoError(t, err) - assert.Equal(t, http.StatusOK, res.StatusCode) + resp, err := (&http.Client{Jar: tc.cj}).Do(req) + require.NoError(t, err, tc.authURL, ts.URL) + defer resp.Body.Close() - body, err := io.ReadAll(res.Body) - require.NoError(t, err) + if tc.expectOAuthAuthError { + require.Empty(t, code) + return + } - var refreshedToken oauth2.Token - require.NoError(t, json.Unmarshal(body, &refreshedToken)) + require.NotEmpty(t, code) - if tc.assertAccessToken != nil { - tc.assertAccessToken(t, refreshedToken.AccessToken) - } + token, err := oauthConfig.Exchange(context.TODO(), code) + if tc.expectOAuthTokenError { + require.Error(t, err) + return + } - t.Run("the tokens should be different", func(t *testing.T) { - if strat.d != "jwt" { - t.Skip() - } + require.NoError(t, err, code) + if tc.assertAccessToken != nil { + tc.assertAccessToken(t, token.AccessToken) + } - body, err := x.DecodeSegment(strings.Split(token.AccessToken, ".")[1]) - require.NoError(t, err) + t.Run("case=userinfo", func(t *testing.T) { + var makeRequest = func(req *http.Request) *http.Response { + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + return resp + } - origPayload := map[string]interface{}{} - require.NoError(t, json.Unmarshal(body, &origPayload)) + var testSuccess = func(response *http.Response) { + defer resp.Body.Close() - body, err = x.DecodeSegment(strings.Split(refreshedToken.AccessToken, ".")[1]) - require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) - refreshedPayload := map[string]interface{}{} - require.NoError(t, json.Unmarshal(body, &refreshedPayload)) + var claims map[string]interface{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&claims)) + assert.Equal(t, "foo", claims["sub"]) + } - if tc.checkExpiry { - assert.NotEqual(t, refreshedPayload["exp"], origPayload["exp"]) - assert.NotEqual(t, refreshedPayload["iat"], origPayload["iat"]) - assert.NotEqual(t, refreshedPayload["nbf"], origPayload["nbf"]) - } - assert.NotEqual(t, refreshedPayload["jti"], origPayload["jti"]) - assert.Equal(t, refreshedPayload["client_id"], origPayload["client_id"]) - }) + req, err = http.NewRequest("GET", ts.URL+"/userinfo", nil) + req.Header.Add("Authorization", "bearer "+token.AccessToken) + testSuccess(makeRequest(req)) - require.NotEqual(t, token.AccessToken, refreshedToken.AccessToken) + req, err = http.NewRequest("POST", ts.URL+"/userinfo", nil) + req.Header.Add("Authorization", "bearer "+token.AccessToken) + testSuccess(makeRequest(req)) - t.Run("old token should no longer be usable", func(t *testing.T) { - req, err := http.NewRequest("GET", ts.URL+"/userinfo", nil) - require.NoError(t, err) - req.Header.Add("Authorization", "bearer "+token.AccessToken) - res, err := http.DefaultClient.Do(req) - require.NoError(t, err) - assert.EqualValues(t, http.StatusUnauthorized, res.StatusCode) - }) + req, err = http.NewRequest("POST", ts.URL+"/userinfo", bytes.NewBuffer([]byte("access_token="+token.AccessToken))) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + testSuccess(makeRequest(req)) - t.Run("refreshing new refresh token should work", func(t *testing.T) { - res, err := testRefresh(t, &refreshedToken, ts.URL, false) - require.NoError(t, err) - assert.Equal(t, http.StatusOK, res.StatusCode) + req, err = http.NewRequest("GET", ts.URL+"/userinfo", nil) + req.Header.Add("Authorization", "bearer asdfg") + resp := makeRequest(req) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) - body, err := io.ReadAll(res.Body) - require.NoError(t, err) - require.NoError(t, json.Unmarshal(body, &refreshedToken)) - }) - - t.Run("should call refresh token hook if configured", func(t *testing.T) { - run := func(hookType 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") - - expectedGrantedScopes := []string{"openid", "offline", "hydra.*"} - expectedSubject := "foo" - - exceptKeys := []string{ - "session.kid", - "session.id_token.expires_at", - "session.id_token.headers.extra.kid", - "session.id_token.id_token_claims.iat", - "session.id_token.id_token_claims.exp", - "session.id_token.id_token_claims.rat", - "session.id_token.id_token_claims.auth_time", - } + res, err := testRefresh(t, token, ts.URL, tc.checkExpiry) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, res.StatusCode) - if hookType == "legacy" { - var hookReq hydraoauth2.RefreshTokenHookRequest - require.NoError(t, json.NewDecoder(r.Body).Decode(&hookReq)) - require.Equal(t, hookReq.Subject, expectedSubject) - require.ElementsMatch(t, hookReq.GrantedScopes, expectedGrantedScopes) - require.ElementsMatch(t, hookReq.GrantedAudience, []string{}) - require.Equal(t, hookReq.ClientID, oauthConfig.ClientID) - require.NotEmpty(t, hookReq.Session) - require.Equal(t, hookReq.Session.Subject, expectedSubject) - require.Equal(t, hookReq.Session.ClientID, oauthConfig.ClientID) - require.NotEmpty(t, hookReq.Requester) - require.Equal(t, hookReq.Requester.ClientID, oauthConfig.ClientID) - require.ElementsMatch(t, hookReq.Requester.GrantedScopes, expectedGrantedScopes) - - snapshotx.SnapshotT(t, hookReq, snapshotx.ExceptPaths(exceptKeys...)) - } else { - var hookReq hydraoauth2.TokenHookRequest - require.NoError(t, json.NewDecoder(r.Body).Decode(&hookReq)) - require.NotEmpty(t, hookReq.Session) - require.Equal(t, hookReq.Session.Subject, expectedSubject) - require.Equal(t, hookReq.Session.ClientID, oauthConfig.ClientID) - require.NotEmpty(t, hookReq.Request) - require.Equal(t, hookReq.Request.ClientID, oauthConfig.ClientID) - require.ElementsMatch(t, hookReq.Request.GrantedScopes, expectedGrantedScopes) - require.ElementsMatch(t, hookReq.Request.GrantedAudience, []string{}) - require.Equal(t, hookReq.Request.Payload, map[string][]string{"grant_type": {"refresh_token"}}) - - snapshotx.SnapshotT(t, hookReq, snapshotx.ExceptPaths(exceptKeys...)) - } + body, err := io.ReadAll(res.Body) + require.NoError(t, err) - claims := map[string]interface{}{ - "hooked": hookType, - } + var refreshedToken oauth2.Token + require.NoError(t, json.Unmarshal(body, &refreshedToken)) - hookResp := hydraoauth2.TokenHookResponse{ - Session: flow.AcceptOAuth2ConsentRequestSession{ - AccessToken: claims, - IDToken: claims, - }, - } + if tc.assertAccessToken != nil { + tc.assertAccessToken(t, refreshedToken.AccessToken) + } - w.WriteHeader(http.StatusOK) - require.NoError(t, json.NewEncoder(w).Encode(&hookResp)) - })) - defer hs.Close() - - 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) + t.Run("the tokens should be different", func(t *testing.T) { + if strat.d != "jwt" { + t.Skip() } - res, err := testRefresh(t, &refreshedToken, ts.URL, false) + body, err := x.DecodeSegment(strings.Split(token.AccessToken, ".")[1]) require.NoError(t, err) - assert.Equal(t, http.StatusOK, res.StatusCode) - body, err := io.ReadAll(res.Body) - require.NoError(t, err) - require.NoError(t, json.Unmarshal(body, &refreshedToken)) - - accessTokenClaims := testhelpers.IntrospectToken(t, oauthConfig, refreshedToken.AccessToken, ts) - require.Equal(t, accessTokenClaims.Get("ext.hooked").String(), hookType) + origPayload := map[string]interface{}{} + require.NoError(t, json.Unmarshal(body, &origPayload)) - idTokenBody, err := x.DecodeSegment( - strings.Split( - gjson.GetBytes(body, "id_token").String(), - ".", - )[1], - ) + body, err = x.DecodeSegment(strings.Split(refreshedToken.AccessToken, ".")[1]) require.NoError(t, err) - require.Equal(t, gjson.GetBytes(idTokenBody, "hooked").String(), hookType) - } - } - t.Run("hook=legacy", run("legacy")) - t.Run("hook=new", run("new")) - }) + refreshedPayload := map[string]interface{}{} + require.NoError(t, json.Unmarshal(body, &refreshedPayload)) - t.Run("should not override session data if token refresh hook returns no content", func(t *testing.T) { - run := func(hookType 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.StatusNoContent) - })) - defer hs.Close() - - 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) + if tc.checkExpiry { + assert.NotEqual(t, refreshedPayload["exp"], origPayload["exp"]) + assert.NotEqual(t, refreshedPayload["iat"], origPayload["iat"]) + assert.NotEqual(t, refreshedPayload["nbf"], origPayload["nbf"]) } + assert.NotEqual(t, refreshedPayload["jti"], origPayload["jti"]) + assert.Equal(t, refreshedPayload["client_id"], origPayload["client_id"]) + }) + + require.NotEqual(t, token.AccessToken, refreshedToken.AccessToken) - origAccessTokenClaims := testhelpers.IntrospectToken(t, oauthConfig, refreshedToken.AccessToken, ts) + t.Run("old token should no longer be usable", func(t *testing.T) { + req, err := http.NewRequest("GET", ts.URL+"/userinfo", nil) + require.NoError(t, err) + req.Header.Add("Authorization", "bearer "+token.AccessToken) + res, err := http.DefaultClient.Do(req) + require.NoError(t, err) + assert.EqualValues(t, http.StatusUnauthorized, res.StatusCode) + }) + t.Run("refreshing new refresh token should work", func(t *testing.T) { res, err := testRefresh(t, &refreshedToken, ts.URL, false) require.NoError(t, err) assert.Equal(t, http.StatusOK, res.StatusCode) - body, err = io.ReadAll(res.Body) + body, err := io.ReadAll(res.Body) require.NoError(t, err) - require.NoError(t, json.Unmarshal(body, &refreshedToken)) - - refreshedAccessTokenClaims := testhelpers.IntrospectToken(t, oauthConfig, refreshedToken.AccessToken, ts) - assertx.EqualAsJSONExcept(t, json.RawMessage(origAccessTokenClaims.Raw), json.RawMessage(refreshedAccessTokenClaims.Raw), []string{"exp", "iat", "nbf"}) - } - } - t.Run("hook=legacy", run("legacy")) - t.Run("hook=new", run("new")) - }) - - t.Run("should fail token refresh with `server_error` if refresh hook fails", func(t *testing.T) { - run := func(hookType 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() - - 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) + }) + + t.Run("should call refresh token hook if configured", func(t *testing.T) { + run := func(hookType 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") + + expectedGrantedScopes := []string{"openid", "offline", "hydra.*"} + expectedSubject := "foo" + + exceptKeys := []string{ + "session.kid", + "session.id_token.expires_at", + "session.id_token.headers.extra.kid", + "session.id_token.id_token_claims.iat", + "session.id_token.id_token_claims.exp", + "session.id_token.id_token_claims.rat", + "session.id_token.id_token_claims.auth_time", + } + + if hookType == "legacy" { + var hookReq hydraoauth2.RefreshTokenHookRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&hookReq)) + require.Equal(t, hookReq.Subject, expectedSubject) + require.ElementsMatch(t, hookReq.GrantedScopes, expectedGrantedScopes) + require.ElementsMatch(t, hookReq.GrantedAudience, []string{}) + require.Equal(t, hookReq.ClientID, oauthConfig.ClientID) + require.NotEmpty(t, hookReq.Session) + require.Equal(t, hookReq.Session.Subject, expectedSubject) + require.Equal(t, hookReq.Session.ClientID, oauthConfig.ClientID) + require.NotEmpty(t, hookReq.Requester) + require.Equal(t, hookReq.Requester.ClientID, oauthConfig.ClientID) + require.ElementsMatch(t, hookReq.Requester.GrantedScopes, expectedGrantedScopes) + + snapshotx.SnapshotT(t, hookReq, snapshotx.ExceptPaths(exceptKeys...)) + } else { + var hookReq hydraoauth2.TokenHookRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&hookReq)) + require.NotEmpty(t, hookReq.Session) + require.Equal(t, hookReq.Session.Subject, expectedSubject) + require.Equal(t, hookReq.Session.ClientID, oauthConfig.ClientID) + require.NotEmpty(t, hookReq.Request) + require.Equal(t, hookReq.Request.ClientID, oauthConfig.ClientID) + require.ElementsMatch(t, hookReq.Request.GrantedScopes, expectedGrantedScopes) + require.ElementsMatch(t, hookReq.Request.GrantedAudience, []string{}) + require.Equal(t, hookReq.Request.Payload, map[string][]string{"grant_type": {"refresh_token"}}) + + snapshotx.SnapshotT(t, hookReq, snapshotx.ExceptPaths(exceptKeys...)) + } + + claims := map[string]interface{}{ + "hooked": hookType, + } + + hookResp := hydraoauth2.TokenHookResponse{ + Session: flow.AcceptOAuth2ConsentRequestSession{ + AccessToken: claims, + IDToken: claims, + }, + } + + w.WriteHeader(http.StatusOK) + require.NoError(t, json.NewEncoder(w).Encode(&hookResp)) + })) + defer hs.Close() + + 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) + } + + res, err := testRefresh(t, &refreshedToken, ts.URL, false) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, res.StatusCode) + + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(body, &refreshedToken)) + + accessTokenClaims := testhelpers.IntrospectToken(t, oauthConfig, refreshedToken.AccessToken, ts) + require.Equal(t, accessTokenClaims.Get("ext.hooked").String(), hookType) + + idTokenBody, err := x.DecodeSegment( + strings.Split( + gjson.GetBytes(body, "id_token").String(), + ".", + )[1], + ) + require.NoError(t, err) + + require.Equal(t, gjson.GetBytes(idTokenBody, "hooked").String(), hookType) + } } - - res, err := testRefresh(t, &refreshedToken, ts.URL, false) - require.NoError(t, err) - assert.Equal(t, http.StatusInternalServerError, res.StatusCode) - - var errBody fosite.RFC6749ErrorJson - require.NoError(t, json.NewDecoder(res.Body).Decode(&errBody)) - require.Equal(t, fosite.ErrServerError.Error(), errBody.Name) - require.Equal(t, "An error occurred while executing the token hook.", errBody.Description) - } - } - t.Run("hook=legacy", run("legacy")) - t.Run("hook=new", run("new")) - }) - - t.Run("should fail token refresh with `access_denied` if legacy refresh hook denied the request", func(t *testing.T) { - run := func(hookType 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() - - 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) + t.Run("hook=legacy", run("legacy")) + t.Run("hook=new", run("new")) + }) + + t.Run("should not override session data if token refresh hook returns no content", func(t *testing.T) { + run := func(hookType 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.StatusNoContent) + })) + defer hs.Close() + + 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) + } + + origAccessTokenClaims := testhelpers.IntrospectToken(t, oauthConfig, refreshedToken.AccessToken, ts) + + res, err := testRefresh(t, &refreshedToken, ts.URL, false) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, res.StatusCode) + + body, err = io.ReadAll(res.Body) + require.NoError(t, err) + + require.NoError(t, json.Unmarshal(body, &refreshedToken)) + + refreshedAccessTokenClaims := testhelpers.IntrospectToken(t, oauthConfig, refreshedToken.AccessToken, ts) + assertx.EqualAsJSONExcept(t, json.RawMessage(origAccessTokenClaims.Raw), json.RawMessage(refreshedAccessTokenClaims.Raw), []string{"exp", "iat", "nbf"}) + } + } + t.Run("hook=legacy", run("legacy")) + t.Run("hook=new", run("new")) + }) + + t.Run("should fail token refresh with `server_error` if refresh hook fails", func(t *testing.T) { + run := func(hookType 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() + + 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) + } + + res, err := testRefresh(t, &refreshedToken, ts.URL, false) + require.NoError(t, err) + assert.Equal(t, http.StatusInternalServerError, res.StatusCode) + + var errBody fosite.RFC6749ErrorJson + require.NoError(t, json.NewDecoder(res.Body).Decode(&errBody)) + require.Equal(t, fosite.ErrServerError.Error(), errBody.Name) + require.Equal(t, "An error occurred while executing the token hook.", errBody.Description) + } } + t.Run("hook=legacy", run("legacy")) + t.Run("hook=new", run("new")) + }) + + t.Run("should fail token refresh with `access_denied` if legacy refresh hook denied the request", func(t *testing.T) { + run := func(hookType 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() + + 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) + } + + res, err := testRefresh(t, &refreshedToken, ts.URL, false) + require.NoError(t, err) + assert.Equal(t, http.StatusForbidden, res.StatusCode) + + var errBody fosite.RFC6749ErrorJson + require.NoError(t, json.NewDecoder(res.Body).Decode(&errBody)) + require.Equal(t, fosite.ErrAccessDenied.Error(), errBody.Name) + require.Equal(t, "The token hook target responded with an error. Make sure that the request you are making is valid. Maybe the credential or request parameters you are using are limited in scope or otherwise restricted.", errBody.Description) + } + } + t.Run("hook=legacy", run("legacy")) + t.Run("hook=new", run("new")) + }) + + t.Run("should fail token refresh with `server_error` if refresh hook response is malformed", func(t *testing.T) { + run := func(hookType 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() + + 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) + } + + res, err := testRefresh(t, &refreshedToken, ts.URL, false) + require.NoError(t, err) + assert.Equal(t, http.StatusInternalServerError, res.StatusCode) + + var errBody fosite.RFC6749ErrorJson + require.NoError(t, json.NewDecoder(res.Body).Decode(&errBody)) + require.Equal(t, fosite.ErrServerError.Error(), errBody.Name) + require.Equal(t, "The token hook target responded with an error.", errBody.Description) + } + } + t.Run("hook=legacy", run("legacy")) + t.Run("hook=new", run("new")) + }) - res, err := testRefresh(t, &refreshedToken, ts.URL, false) + 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.StatusForbidden, res.StatusCode) - - var errBody fosite.RFC6749ErrorJson - require.NoError(t, json.NewDecoder(res.Body).Decode(&errBody)) - require.Equal(t, fosite.ErrAccessDenied.Error(), errBody.Name) - require.Equal(t, "The token hook target responded with an error. Make sure that the request you are making is valid. Maybe the credential or request parameters you are using are limited in scope or otherwise restricted.", errBody.Description) - } - } - t.Run("hook=legacy", run("legacy")) - t.Run("hook=new", run("new")) - }) - - t.Run("should fail token refresh with `server_error` if refresh hook response is malformed", func(t *testing.T) { - run := func(hookType 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() - - 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) - } + assert.Equal(t, http.StatusUnauthorized, 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.StatusInternalServerError, res.StatusCode) - - var errBody fosite.RFC6749ErrorJson - require.NoError(t, json.NewDecoder(res.Body).Decode(&errBody)) - require.Equal(t, fosite.ErrServerError.Error(), errBody.Name) - require.Equal(t, "The token hook target responded with an error.", errBody.Description) - } - } - t.Run("hook=legacy", run("legacy")) - t.Run("hook=new", run("new")) - }) - - 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.StatusUnauthorized, 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) - }) + t.Run("duplicate code exchange fails", func(t *testing.T) { + token, err := oauthConfig.Exchange(context.TODO(), code) + require.Error(t, err) + require.Nil(t, token) + }) - t.Run("duplicate code exchange fails", func(t *testing.T) { - token, err := oauthConfig.Exchange(context.TODO(), code) - require.Error(t, err) - require.Nil(t, token) - }) - - code = "" + code = "" + }) + } }) } })