Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
27 changes: 26 additions & 1 deletion api/envoy/extensions/filters/http/oauth2/v3/oauth.proto
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ message OAuth2Credentials {
// The secret used to retrieve the access token. This value will be URL encoded when sent to the OAuth server.
// This field is required unless :ref:`auth_type <envoy_v3_api_field_extensions.filters.http.oauth2.v3.OAuth2Config.auth_type>`
// is set to ``TLS_CLIENT_AUTH``, in which case authentication is done via the client certificate.
// When ``auth_type`` is ``PRIVATE_KEY_JWT``, this field must contain the PEM-encoded private key
// used to sign the JWT client assertion.
transport_sockets.tls.v3.SdsSecretConfig token_secret = 2;

// Configures how the secret token should be created.
Expand All @@ -149,9 +151,21 @@ message OAuth2Credentials {
[(validate.rules).string = {pattern: "^$|^[^\\x00-\\x1f\\x7f \",;<>\\\\]+$"}];
}

// Configuration for ``PRIVATE_KEY_JWT`` client authentication (RFC 7523).
message PrivateKeyJwtConfig {
// The signing algorithm to use for the JWT assertion.
// Supported values: ``RS256``, ``RS384``, ``RS512``, ``ES256``, ``ES384``, ``ES512``.
// Default: ``RS256``.
string signing_algorithm = 1;

// The lifetime of the JWT assertion. After this duration, the assertion expires.
// Default: ``60s``.
google.protobuf.Duration assertion_lifetime = 2;
}

// OAuth config
//
// [#next-free-field: 27]
// [#next-free-field: 28]
message OAuth2Config {
enum AuthType {
// The ``client_id`` and ``client_secret`` will be sent in the URL encoded request body.
Expand All @@ -168,6 +182,12 @@ message OAuth2Config {
// transport socket configuration.
// This implements OAuth 2.0 Mutual-TLS Client Authentication as defined in RFC 8705.
TLS_CLIENT_AUTH = 2;

// The client authenticates using a signed JWT assertion (RFC 7523).
// The ``token_secret`` in credentials must contain the PEM-encoded private key used to sign the assertion.
// The JWT assertion is sent as ``client_assertion`` in the token request body along with
// ``client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer``.
PRIVATE_KEY_JWT = 3;
}

// Endpoint on the authorization server to retrieve the access token from.
Expand Down Expand Up @@ -292,6 +312,11 @@ message OAuth2Config {
// This option should only be used in secure environments where token encryption is not required.
// Default is false (tokens are encrypted).
bool disable_token_encryption = 26;

// Configuration for ``PRIVATE_KEY_JWT`` client authentication.
// Only used when :ref:`auth_type <envoy_v3_api_field_extensions.filters.http.oauth2.v3.OAuth2Config.auth_type>`
// is set to ``PRIVATE_KEY_JWT``.
PrivateKeyJwtConfig private_key_jwt = 27;
}

// Filter config.
Expand Down
5 changes: 5 additions & 0 deletions changelogs/current.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,11 @@ removed_config_or_runtime:
# *Normally occurs at the end of the* :ref:`deprecation period <deprecated>`

new_features:
- area: oauth2
change: |
Added ``PRIVATE_KEY_JWT`` client authentication to the OAuth2 filter (`RFC 7523 <https://datatracker.ietf.org/doc/html/rfc7523>`_).
The client authenticates using a signed JWT assertion sent as ``client_assertion`` in the token request.
The PEM-encoded private key is provided via the existing ``token_secret`` SDS configuration.
- area: dynamic_modules
change: |
Added upstream HTTP TCP bridge extension for dynamic modules. This enables modules to transform
Expand Down
12 changes: 12 additions & 0 deletions source/extensions/filters/http/oauth2/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,23 @@ envoy_cc_library(
],
)

envoy_cc_library(
name = "client_assertion_lib",
srcs = ["client_assertion.cc"],
hdrs = ["client_assertion.h"],
deps = [
"//source/common/common:base64_lib",
"//source/common/crypto:utility_lib",
"//source/common/json:json_sanitizer_lib",
],
)

envoy_cc_library(
name = "oauth_lib",
srcs = ["filter.cc"],
hdrs = ["filter.h"],
deps = [
":client_assertion_lib",
":oauth_client",
"//envoy/server:filter_config_interface",
"//source/common/common:assert_lib",
Expand Down
99 changes: 99 additions & 0 deletions source/extensions/filters/http/oauth2/client_assertion.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#include "source/extensions/filters/http/oauth2/client_assertion.h"

#include <chrono>
#include <string>
#include <vector>

#include "source/common/common/base64.h"
#include "source/common/crypto/utility.h"
#include "source/common/json/json_sanitizer.h"

#include "absl/strings/ascii.h"
#include "absl/strings/str_cat.h"

namespace Envoy {
namespace Extensions {
namespace HttpFilters {
namespace Oauth2 {

namespace {

absl::StatusOr<std::string> getHashFunction(absl::string_view algorithm) {
const std::string alg(absl::AsciiStrToUpper(algorithm));
if (alg == "RS256" || alg == "ES256") {
return std::string("sha256");
}
if (alg == "RS384" || alg == "ES384") {
return std::string("sha384");
}
if (alg == "RS512" || alg == "ES512") {
return std::string("sha512");
}
return absl::InvalidArgumentError(absl::StrCat("Unsupported signing algorithm: ", algorithm));
}

std::string base64UrlEncode(absl::string_view input) {
return Base64Url::encode(input.data(), input.size());
}

} // namespace

absl::StatusOr<std::string>
ClientAssertion::create(absl::string_view client_id, absl::string_view audience,
absl::string_view private_key_pem, absl::string_view algorithm,
std::chrono::seconds lifetime, TimeSource& time_source,
Random::RandomGenerator& random) {
const auto hash_func = getHashFunction(algorithm);
if (!hash_func.ok()) {
return hash_func.status();
}

// Import the private key from PEM.
auto pkey = Common::Crypto::UtilitySingleton::get().importPrivateKeyPEM(private_key_pem);
if (pkey == nullptr || pkey->getEVP_PKEY() == nullptr) {
return absl::InvalidArgumentError("Failed to parse private key PEM for JWT signing");
}

const auto now =
std::chrono::duration_cast<std::chrono::seconds>(time_source.systemTime().time_since_epoch());
const auto exp = now + lifetime;
const std::string jti = random.uuid();

// Sanitize strings that will be embedded in JSON to prevent injection.
std::string client_id_buf, audience_buf, jti_buf;
const absl::string_view safe_client_id = Json::sanitize(client_id_buf, client_id);
const absl::string_view safe_audience = Json::sanitize(audience_buf, audience);
const absl::string_view safe_jti = Json::sanitize(jti_buf, jti);

// Build JWT header.
const std::string header = absl::StrCat(R"({"alg":")", algorithm, R"(","typ":"JWT"})");
const std::string encoded_header = base64UrlEncode(header);

// Build JWT payload with required claims per RFC 7523 Section 3.
const std::string payload = absl::StrCat(
R"({"iss":")", safe_client_id, R"(","sub":")", safe_client_id, R"(","aud":")", safe_audience,
R"(","exp":)", exp.count(), R"(,"iat":)", now.count(), R"(,"jti":")", safe_jti, R"("})");
const std::string encoded_payload = base64UrlEncode(payload);

// Sign header.payload.
const std::string signing_input = absl::StrCat(encoded_header, ".", encoded_payload);
const std::vector<uint8_t> text(signing_input.begin(), signing_input.end());

auto signature_result =
Common::Crypto::UtilitySingleton::get().sign(hash_func.value(), *pkey, text);
if (!signature_result.ok()) {
return absl::InternalError(
absl::StrCat("Failed to sign JWT assertion: ", signature_result.status().message()));
}

const auto& sig = signature_result.value();
const std::string encoded_signature =
Base64Url::encode(reinterpret_cast<const char*>(sig.data()), sig.size());

return absl::StrCat(signing_input, ".", encoded_signature);
}

} // namespace Oauth2
} // namespace HttpFilters
} // namespace Extensions
} // namespace Envoy
43 changes: 43 additions & 0 deletions source/extensions/filters/http/oauth2/client_assertion.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#pragma once

#include <chrono>
#include <string>

#include "envoy/common/random_generator.h"
#include "envoy/common/time.h"

#include "absl/status/statusor.h"
#include "absl/strings/string_view.h"

namespace Envoy {
namespace Extensions {
namespace HttpFilters {
namespace Oauth2 {

/**
* Creates signed JWT client assertions for private_key_jwt authentication (RFC 7523).
*/
class ClientAssertion {
public:
/**
* Create a signed JWT assertion.
* @param client_id the OAuth client ID, used as both 'iss' and 'sub' claims.
* @param audience the token endpoint URL, used as the 'aud' claim.
* @param private_key_pem the PEM-encoded private key for signing.
* @param algorithm the signing algorithm (RS256, RS384, RS512, ES256, ES384, ES512).
* @param lifetime the assertion lifetime (used to compute 'exp' from 'iat').
* @param time_source used to get the current time for 'iat' and 'exp' claims.
* @param random used to generate the 'jti' claim.
* @return the signed JWT string on success, or an error status on failure.
*/
static absl::StatusOr<std::string> create(absl::string_view client_id, absl::string_view audience,
absl::string_view private_key_pem,
absl::string_view algorithm,
std::chrono::seconds lifetime, TimeSource& time_source,
Random::RandomGenerator& random);
};

} // namespace Oauth2
} // namespace HttpFilters
} // namespace Extensions
} // namespace Envoy
14 changes: 14 additions & 0 deletions source/extensions/filters/http/oauth2/config.cc
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,20 @@ absl::StatusOr<Http::FilterFactoryCb> OAuth2Config::createFilterFactoryFromProto
return absl::InvalidArgumentError("invalid HMAC secret configuration");
}

if (auth_type ==
envoy::extensions::filters::http::oauth2::v3::OAuth2Config_AuthType_PRIVATE_KEY_JWT) {
if (proto_config.has_private_key_jwt() &&
!proto_config.private_key_jwt().signing_algorithm().empty()) {
const std::string alg = proto_config.private_key_jwt().signing_algorithm();
if (alg != "RS256" && alg != "RS384" && alg != "RS512" && alg != "ES256" && alg != "ES384" &&
alg != "ES512") {
return absl::InvalidArgumentError(
absl::StrCat("unsupported private_key_jwt signing algorithm: ", alg,
". Supported: RS256, RS384, RS512, ES256, ES384, ES512"));
}
}
}

if (proto_config.preserve_authorization_header() && proto_config.forward_bearer_token()) {
return absl::InvalidArgumentError(
"invalid combination of forward_bearer_token and preserve_authorization_header "
Expand Down
45 changes: 43 additions & 2 deletions source/extensions/filters/http/oauth2/filter.cc
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
#include "source/common/protobuf/utility.h"
#include "source/common/router/retry_policy_impl.h"
#include "source/common/runtime/runtime_features.h"
#include "source/extensions/filters/http/oauth2/client_assertion.h"

#include "absl/strings/escaping.h"
#include "absl/strings/match.h"
Expand Down Expand Up @@ -177,6 +178,9 @@ getAuthType(envoy::extensions::filters::http::oauth2::v3::OAuth2Config_AuthType
case envoy::extensions::filters::http::oauth2::v3::OAuth2Config_AuthType::
OAuth2Config_AuthType_TLS_CLIENT_AUTH:
return AuthType::TlsClientAuth;
case envoy::extensions::filters::http::oauth2::v3::OAuth2Config_AuthType::
OAuth2Config_AuthType_PRIVATE_KEY_JWT:
return AuthType::PrivateKeyJwt;
case envoy::extensions::filters::http::oauth2::v3::OAuth2Config_AuthType::
OAuth2Config_AuthType_URL_ENCODED_BODY:
default:
Expand Down Expand Up @@ -479,6 +483,15 @@ FilterConfig::FilterConfig(
disable_access_token_set_cookie_(proto_config.disable_access_token_set_cookie()),
disable_refresh_token_set_cookie_(proto_config.disable_refresh_token_set_cookie()),
disable_token_encryption_(proto_config.disable_token_encryption()),
jwt_signing_algorithm_(proto_config.has_private_key_jwt() &&
!proto_config.private_key_jwt().signing_algorithm().empty()
? proto_config.private_key_jwt().signing_algorithm()
: "RS256"),
jwt_assertion_lifetime_(
std::chrono::seconds(proto_config.has_private_key_jwt()
? PROTOBUF_GET_SECONDS_OR_DEFAULT(proto_config.private_key_jwt(),
assertion_lifetime, 60)
: 60)),
bearer_token_cookie_settings_(
(proto_config.has_cookie_configs() &&
proto_config.cookie_configs().has_bearer_token_cookie_config())
Expand Down Expand Up @@ -739,8 +752,22 @@ Http::FilterHeadersStatus OAuth2Filter::decodeHeaders(Http::RequestHeaderMap& he
*decoder_callbacks_);

// try to update access token by refresh token
std::string client_credential;
if (config_->authType() == AuthType::PrivateKeyJwt) {
auto assertion_result = ClientAssertion::create(
config_->clientId(), config_->tokenEndpointUrl(), config_->clientSecret(),
config_->jwtSigningAlgorithm(), config_->jwtAssertionLifetime(), time_source_, random_);
if (!assertion_result.ok()) {
sendUnauthorizedResponse(fmt::format("Failed to create client assertion: {}",
assertion_result.status().message()));
return Http::FilterHeadersStatus::StopIteration;
}
client_credential = std::move(assertion_result.value());
} else {
client_credential = config_->clientSecret();
}
oauth_client_->asyncRefreshAccessToken(validator_->refreshToken(), config_->clientId(),
config_->clientSecret(), config_->authType());
client_credential, config_->authType());
// pause while we await the next step from the OAuth server
return Http::FilterHeadersStatus::StopAllIterationAndWatermark;
}
Expand Down Expand Up @@ -788,7 +815,21 @@ Http::FilterHeadersStatus OAuth2Filter::decodeHeaders(Http::RequestHeaderMap& he
}
std::string code_verifier = decrypt_result.plaintext;

oauth_client_->asyncGetAccessToken(auth_code_, config_->clientId(), config_->clientSecret(),
std::string client_credential;
if (config_->authType() == AuthType::PrivateKeyJwt) {
auto assertion_result = ClientAssertion::create(
config_->clientId(), config_->tokenEndpointUrl(), config_->clientSecret(),
config_->jwtSigningAlgorithm(), config_->jwtAssertionLifetime(), time_source_, random_);
if (!assertion_result.ok()) {
sendUnauthorizedResponse(fmt::format("Failed to create client assertion: {}",
assertion_result.status().message()));
return Http::FilterHeadersStatus::StopIteration;
}
client_credential = std::move(assertion_result.value());
} else {
client_credential = config_->clientSecret();
}
oauth_client_->asyncGetAccessToken(auth_code_, config_->clientId(), client_credential,
redirect_uri, code_verifier, config_->authType());

// pause while we await the next step from the OAuth server
Expand Down
5 changes: 5 additions & 0 deletions source/extensions/filters/http/oauth2/filter.h
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,9 @@ class FilterConfig : public Logger::Loggable<Logger::Id::oauth2> {
return code_verifier_cookie_settings_;
}
bool disableTokenEncryption() const { return disable_token_encryption_; }
const std::string& jwtSigningAlgorithm() const { return jwt_signing_algorithm_; }
std::chrono::seconds jwtAssertionLifetime() const { return jwt_assertion_lifetime_; }
const std::string& tokenEndpointUrl() const { return oauth_token_endpoint_.uri(); }

private:
static FilterStats generateStats(const std::string& prefix,
Expand Down Expand Up @@ -242,6 +245,8 @@ class FilterConfig : public Logger::Loggable<Logger::Id::oauth2> {
const bool disable_access_token_set_cookie_ : 1;
const bool disable_refresh_token_set_cookie_ : 1;
const bool disable_token_encryption_ : 1;
const std::string jwt_signing_algorithm_;
const std::chrono::seconds jwt_assertion_lifetime_;
Router::RetryPolicyConstSharedPtr retry_policy_;
const CookieSettings bearer_token_cookie_settings_;
const CookieSettings hmac_cookie_settings_;
Expand Down
2 changes: 1 addition & 1 deletion source/extensions/filters/http/oauth2/oauth.h
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class FilterCallbacks {
/**
* Describes the authentication type used by the client when communicating with the auth server.
*/
enum class AuthType { UrlEncodedBody, BasicAuth, TlsClientAuth };
enum class AuthType { UrlEncodedBody, BasicAuth, TlsClientAuth, PrivateKeyJwt };

} // namespace Oauth2
} // namespace HttpFilters
Expand Down
Loading
Loading