diff --git a/consent/strategy_default.go b/consent/strategy_default.go index 7b5046ab97..79f5544c7c 100644 --- a/consent/strategy_default.go +++ b/consent/strategy_default.go @@ -713,14 +713,20 @@ func (s *defaultStrategy) executeBackChannelLogout(ctx context.Context, r *http. // s.r.ConsentManager().GetForcedObfuscatedLoginSession(context.Background(), subject, ) // sub := s.obfuscateSubjectIdentifier(c, subject, ) - t, _, err := s.r.OpenIDJWTSigner().Generate(ctx, jwt.MapClaims{ + now := time.Now().UTC() + claims := jwt.MapClaims{ "iss": s.r.Config().IssuerURL(ctx).String(), "aud": []string{c.ID}, - "iat": time.Now().UTC().Unix(), + "iat": now.Unix(), "jti": uuid.New(), "events": map[string]struct{}{"http://schemas.openid.net/event/backchannel-logout": {}}, "sid": sid, - }, &jwt.Headers{ + } + if logoutTokenLifespan := s.r.Config().GetLogoutTokenLifespan(ctx); logoutTokenLifespan > 0 { + claims["exp"] = now.Add(logoutTokenLifespan).Unix() + } + + t, _, err := s.r.OpenIDJWTSigner().Generate(ctx, claims, &jwt.Headers{ Extra: map[string]interface{}{"kid": openIDKeyID}, }) if err != nil { diff --git a/consent/strategy_logout_test.go b/consent/strategy_logout_test.go index 5e21f3fbac..9f0f3dd927 100644 --- a/consent/strategy_logout_test.go +++ b/consent/strategy_logout_test.go @@ -332,6 +332,7 @@ func TestLogoutFlows(t *testing.T) { assert.EqualValues(t, <-sid, logoutToken.Get("sid").String(), logoutToken.Raw) assert.Empty(t, logoutToken.Get("sub").String(), logoutToken.Raw) // The sub claim should be empty because it doesn't work with forced obfuscation and thus we can't easily recover it. assert.Empty(t, logoutToken.Get("nonce").String(), logoutToken.Raw) + assert.False(t, logoutToken.Get("exp").Exists(), "exp claim should not be present when ttl.logout_token is not set") }) t.Run("method=get", testExpectPostLogoutPage(createBrowserWithSession(t, c), http.MethodGet, url.Values{}, defaultRedirectedMessage)) @@ -342,6 +343,36 @@ func TestLogoutFlows(t *testing.T) { backChannelWG.Wait() // we want to ensure that all back channels have been called! }) + t.Run("case=should include exp claim in logout token when ttl.logout_token is set", func(t *testing.T) { + require.NoError(t, reg.Config().Source(ctx).Set(config.KeyLogoutTokenLifespan, "2m")) + t.Cleanup(func() { + require.NoError(t, reg.Config().Source(ctx).Set(config.KeyLogoutTokenLifespan, "0s")) + }) + + sid := acceptLoginAsAndWatchSid(t, subject) + + logoutWg := newWg(2) + setupCheckAndAcceptLogoutHandler(t, logoutWg, nil) + + backChannelWG := newWg(2) + c := createClientWithBackchannelLogout(t, backChannelWG, func(t *testing.T, logoutToken gjson.Result) { + assert.EqualValues(t, <-sid, logoutToken.Get("sid").String(), logoutToken.Raw) + assert.True(t, logoutToken.Get("exp").Exists(), "exp claim should be present when ttl.logout_token is set") + + iat := logoutToken.Get("iat").Int() + exp := logoutToken.Get("exp").Int() + diff := exp - iat + assert.InDelta(t, 120, diff, 2, "exp should be approximately iat + 120 seconds") + }) + + t.Run("method=get", testExpectPostLogoutPage(createBrowserWithSession(t, c), http.MethodGet, url.Values{}, defaultRedirectedMessage)) + + t.Run("method=post", testExpectPostLogoutPage(createBrowserWithSession(t, c), http.MethodPost, url.Values{}, defaultRedirectedMessage)) + + logoutWg.Wait() + backChannelWG.Wait() + }) + // Only do GET requests from here on out, POST should be tested enough to ensure that it is working fine already. t.Run("case=should fail several flows when id_token_hint is invalid", func(t *testing.T) { diff --git a/driver/config/provider.go b/driver/config/provider.go index 737c95e5c4..ebe9669609 100644 --- a/driver/config/provider.go +++ b/driver/config/provider.go @@ -75,6 +75,7 @@ const ( KeyIDTokenLifespan = "ttl.id_token" // #nosec G101 KeyAuthCodeLifespan = "ttl.auth_code" KeyDeviceAndUserCodeLifespan = "ttl.device_user_code" + KeyLogoutTokenLifespan = "ttl.logout_token" // #nosec G101 KeyAuthenticationSessionLifespan = "ttl.authentication_session" KeyScopeStrategy = "strategies.scope" KeyGetCookieSecrets = "secrets.cookie" @@ -429,6 +430,11 @@ func (p *DefaultProvider) GetDeviceAndUserCodeLifespan(ctx context.Context) time return p.p.DurationF(KeyDeviceAndUserCodeLifespan, time.Minute*15) } +// GetLogoutTokenLifespan returns the logout_token lifespan. Defaults to 0 (no exp claim). +func (p *DefaultProvider) GetLogoutTokenLifespan(ctx context.Context) time.Duration { + return p.p.DurationF(KeyLogoutTokenLifespan, 0) +} + // GetAuthenticationSessionLifespan returns the authentication_session lifespan. func (p *DefaultProvider) GetAuthenticationSessionLifespan(ctx context.Context) time.Duration { lifespan := p.p.Duration(KeyAuthenticationSessionLifespan) diff --git a/oryx/httpx/ssrf.go b/oryx/httpx/ssrf.go index 4d8bd307b8..cfa52b9d57 100644 --- a/oryx/httpx/ssrf.go +++ b/oryx/httpx/ssrf.go @@ -11,9 +11,10 @@ import ( "time" "github.com/gobwas/glob" - "github.com/ory/x/ipx" "go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + + "github.com/ory/x/ipx" ) var _ http.RoundTripper = (*noInternalIPRoundTripper)(nil) diff --git a/oryx/otelx/middleware.go b/oryx/otelx/middleware.go index 9de7ba9b64..2b7c6f4571 100644 --- a/oryx/otelx/middleware.go +++ b/oryx/otelx/middleware.go @@ -8,8 +8,9 @@ import ( "net/http" "strings" - "github.com/ory/x/httprouterx" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + + "github.com/ory/x/httprouterx" ) var withDefaultFilters = otelhttp.WithFilter(func(r *http.Request) bool { diff --git a/oryx/prometheusx/metrics.go b/oryx/prometheusx/metrics.go index a0f394c2ec..0508f421f0 100644 --- a/oryx/prometheusx/metrics.go +++ b/oryx/prometheusx/metrics.go @@ -11,10 +11,11 @@ import ( "time" grpcPrometheus "github.com/grpc-ecosystem/go-grpc-prometheus" - "github.com/ory/x/httprouterx" "github.com/pkg/errors" "github.com/prometheus/client_golang/prometheus" "github.com/urfave/negroni" + + "github.com/ory/x/httprouterx" ) type HTTPMetrics struct { diff --git a/oryx/region/region.go b/oryx/region/region.go index 65de5a459b..8851a07295 100644 --- a/oryx/region/region.go +++ b/oryx/region/region.go @@ -5,8 +5,9 @@ package region import ( "database/sql/driver" - "github.com/ory/herodot" "github.com/pkg/errors" + + "github.com/ory/herodot" ) // Region is an Ory Network region. Specific regions map to a single CRDB diff --git a/oryx/watcherx/directory.go b/oryx/watcherx/directory.go index 1778a0452e..7ac6869b33 100644 --- a/oryx/watcherx/directory.go +++ b/oryx/watcherx/directory.go @@ -53,7 +53,7 @@ type directoryWatcher struct { // potentially by external callers (e.g. tests) concurrently. subDirsMtx sync.RWMutex subDirs map[string]struct{} - w *fsnotify.Watcher + w *fsnotify.Watcher } func (w *directoryWatcher) handleEvent(ctx context.Context, e fsnotify.Event) { diff --git a/spec/config.json b/spec/config.json index d4e0ca7b16..24fff51c27 100644 --- a/spec/config.json +++ b/spec/config.json @@ -726,6 +726,15 @@ "$ref": "#/definitions/duration" } ] + }, + "logout_token": { + "description": "Configures how long logout tokens are valid. If set to \"0s\" (zero seconds) or left unset, no exp claim will be added to the logout token (preserving backward compatibility). The value must use the duration string format. The OpenID Connect Back-Channel Logout specification recommends a value of at most two minutes (e.g. \"2m\").", + "type": "string", + "allOf": [ + { + "$ref": "#/definitions/duration" + } + ] } } },