diff --git a/api/envoy/extensions/filters/http/oauth2/v3/oauth.proto b/api/envoy/extensions/filters/http/oauth2/v3/oauth.proto index cf05c6db4fac5..2c59740935b63 100644 --- a/api/envoy/extensions/filters/http/oauth2/v3/oauth.proto +++ b/api/envoy/extensions/filters/http/oauth2/v3/oauth.proto @@ -159,7 +159,7 @@ message OAuth2TokenForwarding { // OAuth config // -// [#next-free-field: 32] +// [#next-free-field: 33] message OAuth2Config { enum AuthType { // The ``client_id`` and ``client_secret`` will be sent in the URL encoded request body. @@ -370,6 +370,16 @@ message OAuth2Config { // the ID token and you want the ID token cookie to remain valid for that full duration. // Default is false (use the ID token's own ``exp`` claim when available). bool use_access_token_expiry_for_id_token_cookie = 30; + + // Optional duration to subtract from OAuth2 cookie lifetimes. + // + // When set, the OAuth2 filter sets cookies that contain or validate OAuth2 tokens to expire this + // much earlier than their corresponding token lifetime. This can be used to trigger + // re-authentication or refresh before upstream services receive a token that is about to expire. + // + // If the margin is greater than or equal to a token lifetime, the cookie lifetime is set to + // ``0s``. + google.protobuf.Duration cookie_expiration_margin = 32 [(validate.rules).duration = {gte {}}]; } // Per-route OAuth2 config. diff --git a/changelogs/current/new_features/oauth2__cookie-expiration-margin.rst b/changelogs/current/new_features/oauth2__cookie-expiration-margin.rst new file mode 100644 index 0000000000000..27da9d9ae2a2a --- /dev/null +++ b/changelogs/current/new_features/oauth2__cookie-expiration-margin.rst @@ -0,0 +1,4 @@ +Added :ref:`cookie_expiration_margin +` +to the OAuth2 filter, allowing OAuth2 cookies that contain or validate tokens to expire before the +corresponding tokens. diff --git a/docs/root/configuration/http/http_filters/oauth2_filter.rst b/docs/root/configuration/http/http_filters/oauth2_filter.rst index 571ba7b2bb2ef..aaa25259359c4 100644 --- a/docs/root/configuration/http/http_filters/oauth2_filter.rst +++ b/docs/root/configuration/http/http_filters/oauth2_filter.rst @@ -51,6 +51,11 @@ The OAuth filter's flow involves: * The filter sets ``IdToken`` and ``RefreshToken`` cookies if they are provided by Identity provider along with ``AccessToken``. These cookie names can be customized by setting :ref:`cookie_names `. +The OAuth2 cookies can be configured with a +:ref:`cookie_expiration_margin ` +so that they expire before the corresponding tokens. This can be used to force refresh or +re-authentication before forwarding a token that is close to expiration. + When the authn server validates the client and returns an authorization token back to the OAuth filter, no matter what format that token is, if :ref:`forward_bearer_token ` @@ -118,6 +123,8 @@ The following is an example configuring the filter. - user - openid - email + # (Optional): expire OAuth cookies this much earlier than their corresponding tokens + cookie_expiration_margin: 30s # (Optional): set resource parameter for Authorization request resources: - oauth2-resource diff --git a/source/extensions/filters/http/oauth2/filter.cc b/source/extensions/filters/http/oauth2/filter.cc index 9b0f895bef697..e830cd0eb6c57 100644 --- a/source/extensions/filters/http/oauth2/filter.cc +++ b/source/extensions/filters/http/oauth2/filter.cc @@ -485,6 +485,11 @@ DecryptResult decryptCbc(absl::string_view encrypted, absl::string_view secret) return {std::string(plaintext.begin(), plaintext.end()), std::nullopt}; } +std::chrono::seconds applyCookieExpirationMargin(std::chrono::seconds expires_in, + std::chrono::seconds margin) { + return margin >= expires_in ? std::chrono::seconds(0) : expires_in - margin; +} + } // namespace std::string encrypt(absl::string_view plaintext, absl::string_view secret, @@ -669,6 +674,8 @@ FilterConfig::FilterConfig( default_expires_in_(PROTOBUF_GET_SECONDS_OR_DEFAULT(proto_config, default_expires_in, 0)), default_refresh_token_expires_in_( PROTOBUF_GET_SECONDS_OR_DEFAULT(proto_config, default_refresh_token_expires_in, 604800)), + cookie_expiration_margin_( + PROTOBUF_GET_SECONDS_OR_DEFAULT(proto_config, cookie_expiration_margin, 0)), csrf_token_expires_in_(PROTOBUF_GET_SECONDS_OR_DEFAULT(proto_config, csrf_token_expires_in, DEFAULT_CSRF_TOKEN_EXPIRES_IN)), code_verifier_token_expires_in_(PROTOBUF_GET_SECONDS_OR_DEFAULT( @@ -1398,11 +1405,19 @@ void OAuth2Filter::updateTokens(const std::string& access_token, const std::stri refresh_token_ = ""; } - expires_in_ = std::to_string(expires_in.count()); - expires_refresh_token_in_ = getExpiresTimeForRefreshToken(refresh_token, expires_in); - expires_id_token_in_ = getExpiresTimeForIdToken(id_token, expires_in); + const auto cookie_expires_in = + applyCookieExpirationMargin(expires_in, config_->cookieExpirationMargin()); + expires_in_ = std::to_string(cookie_expires_in.count()); + expires_refresh_token_in_ = std::to_string( + applyCookieExpirationMargin(getExpiresTimeForRefreshToken(refresh_token, expires_in), + config_->cookieExpirationMargin()) + .count()); + expires_id_token_in_ = + std::to_string(applyCookieExpirationMargin(getExpiresTimeForIdToken(id_token, expires_in), + config_->cookieExpirationMargin()) + .count()); - const auto new_epoch = time_source_.systemTime() + expires_in; + const auto new_epoch = time_source_.systemTime() + cookie_expires_in; new_expires_ = std::to_string( std::chrono::duration_cast(new_epoch.time_since_epoch()).count()); } @@ -1423,7 +1438,7 @@ std::string OAuth2Filter::getEncodedToken() const { return encoded_token; } -std::string +std::chrono::seconds OAuth2Filter::getExpiresTimeForRefreshToken(const std::string& refresh_token, const std::chrono::seconds& expires_in) const { if (config_->useRefreshToken()) { @@ -1435,31 +1450,29 @@ OAuth2Filter::getExpiresTimeForRefreshToken(const std::string& refresh_token, .time_since_epoch(); if (now < expiration_from_jwt) { - const auto expiration_epoch = expiration_from_jwt - now; - return std::to_string(expiration_epoch.count()); + return expiration_from_jwt - now; } else { ENVOY_STREAM_LOG(debug, "The expiration time in the refresh token is less than the current time", *decoder_callbacks_); - return "0"; + return std::chrono::seconds(0); } } ENVOY_STREAM_LOG(debug, "The refresh token is not a JWT or exp claim is omitted. The lifetime of the " "refresh token will be taken from filter configuration", *decoder_callbacks_); - const std::chrono::seconds default_refresh_token_expires_in = - config_->defaultRefreshTokenExpiresIn(); - return std::to_string(default_refresh_token_expires_in.count()); + return config_->defaultRefreshTokenExpiresIn(); } - return std::to_string(expires_in.count()); + return expires_in; } -std::string OAuth2Filter::getExpiresTimeForIdToken(const std::string& id_token, - const std::chrono::seconds& expires_in) const { +std::chrono::seconds +OAuth2Filter::getExpiresTimeForIdToken(const std::string& id_token, + const std::chrono::seconds& expires_in) const { if (config_->useAccessTokenExpiryForIdTokenCookie()) { - return std::to_string(expires_in.count()); + return expires_in; } if (!id_token.empty()) { JwtVerify::Jwt jwt; @@ -1470,12 +1483,11 @@ std::string OAuth2Filter::getExpiresTimeForIdToken(const std::string& id_token, .time_since_epoch(); if (now < expiration_from_jwt) { - const auto expiration_epoch = expiration_from_jwt - now; - return std::to_string(expiration_epoch.count()); + return expiration_from_jwt - now; } else { ENVOY_STREAM_LOG(debug, "The expiration time in the id token is less than the current time", *decoder_callbacks_); - return "0"; + return std::chrono::seconds(0); } } ENVOY_STREAM_LOG(debug, @@ -1483,9 +1495,9 @@ std::string OAuth2Filter::getExpiresTimeForIdToken(const std::string& id_token, "required by the OpenID Connect 1.0 specification. " "The lifetime of the id token will be aligned with the access token", *decoder_callbacks_); - return std::to_string(expires_in.count()); + return expires_in; } - return std::to_string(expires_in.count()); + return expires_in; } std::string OAuth2Filter::buildCookieTail(const FilterConfig::CookieSettings& settings, diff --git a/source/extensions/filters/http/oauth2/filter.h b/source/extensions/filters/http/oauth2/filter.h index fdc11cff60843..248c4762420bb 100644 --- a/source/extensions/filters/http/oauth2/filter.h +++ b/source/extensions/filters/http/oauth2/filter.h @@ -208,6 +208,7 @@ class FilterConfig : public Router::RouteSpecificFilterConfig, std::chrono::seconds defaultRefreshTokenExpiresIn() const { return default_refresh_token_expires_in_; } + std::chrono::seconds cookieExpirationMargin() const { return cookie_expiration_margin_; } std::chrono::seconds getCsrfTokenExpiresIn() const { return csrf_token_expires_in_; } std::chrono::seconds getCodeVerifierTokenExpiresIn() const { return code_verifier_token_expires_in_; @@ -278,6 +279,7 @@ class FilterConfig : public Router::RouteSpecificFilterConfig, const AuthType auth_type_; const std::chrono::seconds default_expires_in_; const std::chrono::seconds default_refresh_token_expires_in_; + const std::chrono::seconds cookie_expiration_margin_; const std::chrono::seconds csrf_token_expires_in_; const std::chrono::seconds code_verifier_token_expires_in_; const bool forward_bearer_token_ : 1; @@ -450,10 +452,10 @@ class OAuth2Filter : public Http::PassThroughFilter, Http::FilterHeadersStatus signOutUser(const Http::RequestHeaderMap& headers) const; std::string getEncodedToken() const; - std::string getExpiresTimeForRefreshToken(const std::string& refresh_token, - const std::chrono::seconds& expires_in) const; - std::string getExpiresTimeForIdToken(const std::string& id_token, - const std::chrono::seconds& expires_in) const; + std::chrono::seconds getExpiresTimeForRefreshToken(const std::string& refresh_token, + const std::chrono::seconds& expires_in) const; + std::chrono::seconds getExpiresTimeForIdToken(const std::string& id_token, + const std::chrono::seconds& expires_in) const; std::string buildCookieTail(const FilterConfig::CookieSettings& settings, absl::string_view expires_time) const; void setOAuthResponseCookies(Http::ResponseHeaderMap& headers, diff --git a/test/extensions/filters/http/oauth2/filter_test.cc b/test/extensions/filters/http/oauth2/filter_test.cc index f388195fe6f77..586080ebf583c 100644 --- a/test/extensions/filters/http/oauth2/filter_test.cc +++ b/test/extensions/filters/http/oauth2/filter_test.cc @@ -189,7 +189,7 @@ class OAuth2Test : public testing::Test { bool expires_partitioned = false, bool id_token_partitioned = false, bool refresh_token_partitioned = false, bool nonce_partitioned = false, bool code_verifier_partitioned = false, - bool use_access_token_expiry_for_id_token_cookie = false) { + bool use_access_token_expiry_for_id_token_cookie = false, int cookie_expiration_margin = 0) { envoy::extensions::filters::http::oauth2::v3::OAuth2Config p; auto* endpoint = p.mutable_token_endpoint(); @@ -312,6 +312,9 @@ class OAuth2Test : public testing::Test { p.set_disable_token_encryption(disable_token_encryption); p.set_use_access_token_expiry_for_id_token_cookie(use_access_token_expiry_for_id_token_cookie); + if (cookie_expiration_margin != 0) { + p.mutable_cookie_expiration_margin()->set_seconds(cookie_expiration_margin); + } MessageUtil::validate(p, ProtobufMessage::getStrictValidationVisitor()); @@ -323,6 +326,84 @@ class OAuth2Test : public testing::Test { return c; } + void expectAccessTokenSuccessWithCookieExpirationMargin(int cookie_expiration_margin, + const std::string& oauth_hmac, + int access_token_max_age, + int oauth_expires, + int refresh_token_max_age) { + init(getConfig( + true /* forward_bearer_token */, true /* use_refresh_token */, + ::envoy::extensions::filters::http::oauth2::v3::OAuth2Config_AuthType:: + OAuth2Config_AuthType_URL_ENCODED_BODY, + 0 /* default_refresh_token_expires_in */, false /* preserve_authorization_header */, + false /* disable_id_token_set_cookie */, false /* set_cookie_domain */, + false /* disable_access_token_set_cookie */, false /* disable_refresh_token_set_cookie */, + ::envoy::extensions::filters::http::oauth2::v3::CookieConfig_SameSite:: + CookieConfig_SameSite_DISABLED, + ::envoy::extensions::filters::http::oauth2::v3::CookieConfig_SameSite:: + CookieConfig_SameSite_DISABLED, + ::envoy::extensions::filters::http::oauth2::v3::CookieConfig_SameSite:: + CookieConfig_SameSite_DISABLED, + ::envoy::extensions::filters::http::oauth2::v3::CookieConfig_SameSite:: + CookieConfig_SameSite_DISABLED, + ::envoy::extensions::filters::http::oauth2::v3::CookieConfig_SameSite:: + CookieConfig_SameSite_DISABLED, + ::envoy::extensions::filters::http::oauth2::v3::CookieConfig_SameSite:: + CookieConfig_SameSite_DISABLED, + ::envoy::extensions::filters::http::oauth2::v3::CookieConfig_SameSite:: + CookieConfig_SameSite_DISABLED, + 0 /* csrf_token_expires_in */, 0 /* code_verifier_token_expires_in */, + false /* disable_token_encryption */, "" /* bearer_token_path */, "" /* hmac_path */, + "" /* expires_path */, "" /* id_token_path */, "" /* refresh_token_path */, + "" /* nonce_path */, "" /* code_verifier_path */, false /* bearer_partitioned */, + false /* hmac_partitioned */, false /* expires_partitioned */, + false /* id_token_partitioned */, false /* refresh_token_partitioned */, + false /* nonce_partitioned */, false /* code_verifier_partitioned */, + false /* use_access_token_expiry_for_id_token_cookie */, cookie_expiration_margin)); + test_time_.setSystemTime(SystemTime(std::chrono::seconds(1000))); + + Http::TestRequestHeaderMapImpl request_headers{ + {Http::Headers::get().Host.get(), "traffic.example.com"}, + {Http::Headers::get().Path.get(), "/_signout"}, + {Http::Headers::get().Method.get(), Http::Headers::get().MethodValues.Get}, + }; + filter_->decodeHeaders(request_headers, false); + + const std::string access_token_max_age_string = std::to_string(access_token_max_age); + Http::TestRequestHeaderMapImpl expected_headers{ + {Http::Headers::get().Status.get(), "302"}, + {Http::Headers::get().SetCookie.get(), + "OauthHMAC=" + oauth_hmac + ";path=/;Max-Age=" + access_token_max_age_string + + ";secure;HttpOnly"}, + {Http::Headers::get().SetCookie.get(), + "OauthExpires=" + std::to_string(oauth_expires) + + ";path=/;Max-Age=" + access_token_max_age_string + ";secure;HttpOnly"}, + {Http::Headers::get().SetCookie.get(), + "BearerToken=" + TEST_ENCRYPTED_ACCESS_TOKEN + + ";path=/;Max-Age=" + access_token_max_age_string + ";secure;HttpOnly"}, + {Http::Headers::get().SetCookie.get(), + "IdToken=" + TEST_ENCRYPTED_ID_TOKEN + ";path=/;Max-Age=" + access_token_max_age_string + + ";secure;HttpOnly"}, + {Http::Headers::get().SetCookie.get(), + "RefreshToken=" + TEST_ENCRYPTED_REFRESH_TOKEN + + ";path=/;Max-Age=" + std::to_string(refresh_token_max_age) + ";secure;HttpOnly"}, + {Http::Headers::get().SetCookie.get(), + "OauthNonce.00000000075bcd15=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"}, + {Http::Headers::get().SetCookie.get(), + "OauthNonce=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"}, + {Http::Headers::get().SetCookie.get(), + "CodeVerifier.00000000075bcd15=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"}, + {Http::Headers::get().SetCookie.get(), + "CodeVerifier=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"}, + {Http::Headers::get().Location.get(), ""}, + }; + + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(HeaderMapEqualRef(&expected_headers), true)); + + filter_->onGetAccessTokenSuccess("access_code", "some-id-token", "some-refresh-token", + std::chrono::seconds(600)); + } + // Builds a minimal valid config that forwards the ID token on the given header. When // `forward_bearer_token` is true the access token is also forwarded on the Authorization header. FilterConfigSharedPtr getConfigWithIdTokenForwarding(const std::string& id_token_header, @@ -339,15 +420,12 @@ class OAuth2Test : public testing::Test { p.set_stat_prefix("my_prefix"); p.set_forward_bearer_token(forward_bearer_token); p.mutable_forward_id_token()->set_header(id_token_header); - // Disable refresh so the OAuth-failure path is exercised directly without a refresh attempt. p.mutable_use_refresh_token()->set_value(false); - // Allow requests under /allowfailed to continue upstream as unauthenticated when OAuth fails. auto* allow_failed_matcher = p.add_allow_failed_matcher(); allow_failed_matcher->set_name(":path"); allow_failed_matcher->mutable_string_match()->set_prefix("/allowfailed"); - // OPTIONS requests bypass OAuth entirely via pass-through. auto* pass_through_matcher = p.add_pass_through_matcher(); pass_through_matcher->set_name(":method"); pass_through_matcher->mutable_string_match()->set_exact("OPTIONS"); @@ -3538,6 +3616,27 @@ TEST_F(OAuth2Test, OAuthAccessTokenSucessWithTokens) { std::chrono::seconds(600)); } +TEST_F(OAuth2Test, OAuthAccessTokenSucessWithCookieExpirationMargin) { + expectAccessTokenSuccessWithCookieExpirationMargin( + 30 /* cookie_expiration_margin */, + "Af/it5FqmyP2BvV9vWAb1Ql4D85URbQZAj1214Lsn3I=", 570 /* access_token_max_age */, + 1570 /* oauth_expires */, 604770 /* refresh_token_max_age */); +} + +TEST_F(OAuth2Test, OAuthAccessTokenSucessWithCookieExpirationMarginEqualToTokenLifetime) { + expectAccessTokenSuccessWithCookieExpirationMargin( + 600 /* cookie_expiration_margin */, + "edjlOKFmyLakEs7CiDg2pV3ZljY8bViQIb8CSkR87QM=", 0 /* access_token_max_age */, + 1000 /* oauth_expires */, 604200 /* refresh_token_max_age */); +} + +TEST_F(OAuth2Test, OAuthAccessTokenSucessWithCookieExpirationMarginGreaterThanTokenLifetime) { + expectAccessTokenSuccessWithCookieExpirationMargin( + 601 /* cookie_expiration_margin */, + "edjlOKFmyLakEs7CiDg2pV3ZljY8bViQIb8CSkR87QM=", 0 /* access_token_max_age */, + 1000 /* oauth_expires */, 604199 /* refresh_token_max_age */); +} + TEST_F(OAuth2Test, OAuthAccessTokenSucessWithTokensUseRefreshToken) { init(getConfig(true /* forward_bearer_token */, true /* use_refresh_token */)); oauthHMAC = "4TKyxPV/F7yyvr0XgJ2bkWFOc8t4IOFen1k29b84MAQ=;";