Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions api/envoy/extensions/filters/http/oauth2/v3/oauth.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 31 [(validate.rules).duration = {gte {}}];
}

// Per-route OAuth2 config.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Added :ref:`cookie_expiration_margin
<envoy_v3_api_field_extensions.filters.http.oauth2.v3.OAuth2Config.cookie_expiration_margin>`
to the OAuth2 filter, allowing OAuth2 cookies that contain or validate tokens to expire before the
corresponding tokens.
7 changes: 7 additions & 0 deletions docs/root/configuration/http/http_filters/oauth2_filter.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <envoy_v3_api_field_extensions.filters.http.oauth2.v3.OAuth2Credentials.cookie_names>`.

The OAuth2 cookies can be configured with a
:ref:`cookie_expiration_margin <envoy_v3_api_field_extensions.filters.http.oauth2.v3.OAuth2Config.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 <envoy_v3_api_field_extensions.filters.http.oauth2.v3.OAuth2Config.forward_bearer_token>`
Expand Down Expand Up @@ -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
Expand Down
52 changes: 32 additions & 20 deletions source/extensions/filters/http/oauth2/filter.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<std::chrono::seconds>(new_epoch.time_since_epoch()).count());
}
Expand All @@ -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()) {
Expand All @@ -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;
Expand All @@ -1470,22 +1483,21 @@ 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,
"The id token is not a JWT or exp claim is omitted, even though it is "
"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,
Expand Down
10 changes: 6 additions & 4 deletions source/extensions/filters/http/oauth2/filter.h
Original file line number Diff line number Diff line change
Expand Up @@ -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_;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
107 changes: 103 additions & 4 deletions test/extensions/filters/http/oauth2/filter_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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());

Expand All @@ -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,
Expand All @@ -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");
Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would explicitly test the overflow case as well.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, added an explicit cookie_expiration_margin > token lifetime test case

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=;";
Expand Down
Loading