-
Notifications
You must be signed in to change notification settings - Fork 5.3k
OAuth2: PKCE(Proof Key for Code Exchange) #37849
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
21313ef
7dbbde5
c008e80
aac2652
0dd6e3a
60e5bab
6df85ea
62f5537
73860ef
3cf2b43
009cbee
bfdd457
06ee3f1
e45cf89
d0402b5
2eb97c5
fa02bb3
db8f193
daa9271
765b479
248af96
31da834
67e1237
47e9be9
dae8f2b
efa59da
7eeffc1
3e447b4
2d4baf3
ac3b502
05a54cd
69211f5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,6 +27,7 @@ | |
| #include "absl/strings/str_split.h" | ||
| #include "jwt_verify_lib/jwt.h" | ||
| #include "jwt_verify_lib/status.h" | ||
| #include "openssl/rand.h" | ||
|
|
||
| using namespace std::chrono_literals; | ||
|
|
||
|
|
@@ -50,6 +51,8 @@ constexpr absl::string_view queryParamsError = "error"; | |
| constexpr absl::string_view queryParamsCode = "code"; | ||
| constexpr absl::string_view queryParamsState = "state"; | ||
| constexpr absl::string_view queryParamsRedirectUri = "redirect_uri"; | ||
| constexpr absl::string_view queryParamsCodeChallenge = "code_challenge"; | ||
| constexpr absl::string_view queryParamsCodeChallengeMethod = "code_challenge_method"; | ||
|
|
||
| constexpr absl::string_view stateParamsUrl = "url"; | ||
| constexpr absl::string_view stateParamsCsrfToken = "csrf_token"; | ||
|
|
@@ -227,6 +230,30 @@ bool validateCsrfTokenHmac(const std::string& hmac_secret, const std::string& cs | |
| return generateHmacBase64(hmac_secret_vec, token) == hmac; | ||
| } | ||
|
|
||
| // Generates a PKCE code verifier with 32 octets of randomness. | ||
| // This follows recommendations in RFC 7636: | ||
| // https://datatracker.ietf.org/doc/html/rfc7636#section-7.1 | ||
| std::string generateCodeVerifier(Random::RandomGenerator& random) { | ||
| MemBlockBuilder<uint64_t> mem_block(4); | ||
| // create 4 random uint64_t values to fill the buffer because RFC 7636 recommends 32 octets of | ||
| // randomness. | ||
| for (size_t i = 0; i < 4; i++) { | ||
| mem_block.appendOne(random.random()); | ||
| } | ||
|
|
||
| std::unique_ptr<uint64_t[]> data = mem_block.release(); | ||
| return Base64Url::encode(reinterpret_cast<char*>(data.get()), 4 * sizeof(uint64_t)); | ||
| } | ||
|
|
||
| // Generates a PKCE code challenge from a code verifier. | ||
| std::string generateCodeChallenge(const std::string& code_verifier) { | ||
| auto& crypto_util = Envoy::Common::Crypto::UtilitySingleton::get(); | ||
| std::vector<uint8_t> sha256_digest = | ||
| crypto_util.getSha256Digest(Buffer::OwnedImpl(code_verifier)); | ||
| std::string sha256_string(sha256_digest.begin(), sha256_digest.end()); | ||
| return Base64Url::encode(sha256_string.data(), sha256_string.size()); | ||
| } | ||
|
|
||
| /** | ||
| * Encodes the state parameter for the OAuth2 flow. | ||
| * The state parameter is a base64Url encoded JSON object containing the original request URL and a | ||
|
|
@@ -241,6 +268,129 @@ std::string encodeState(const std::string& original_request_url, const std::stri | |
| return Base64Url::encode(json.data(), json.size()); | ||
| } | ||
|
|
||
| /** | ||
| * Encrypt a plaintext string using AES-256-CBC. | ||
| */ | ||
| std::string encrypt(const std::string& plaintext, const std::string& secret, | ||
| Random::RandomGenerator& random) { | ||
| // Generate the key from the secret using SHA-256 | ||
| std::vector<unsigned char> key(SHA256_DIGEST_LENGTH); // AES-256 requires 256-bit (32 bytes) key | ||
| SHA256(reinterpret_cast<const unsigned char*>(secret.c_str()), secret.size(), key.data()); | ||
|
|
||
| // Generate a random IV | ||
| MemBlockBuilder<uint64_t> mem_block(4); | ||
| // create 2 random uint64_t values to fill the buffer because AES-256-CBC requires 16 bytes IV | ||
| for (size_t i = 0; i < 2; i++) { | ||
| mem_block.appendOne(random.random()); | ||
| } | ||
|
|
||
| std::unique_ptr<uint64_t[]> data = mem_block.release(); | ||
| const unsigned char* raw_data = reinterpret_cast<const unsigned char*>(data.get()); | ||
|
|
||
| // AES uses 16-byte IV | ||
| std::vector<unsigned char> iv(16); | ||
| iv.assign(raw_data, raw_data + 16); | ||
|
|
||
| EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new(); | ||
| RELEASE_ASSERT(ctx, "Failed to create context"); | ||
|
|
||
| std::vector<unsigned char> ciphertext(plaintext.size() + EVP_MAX_BLOCK_LENGTH); | ||
| int len = 0, ciphertext_len = 0; | ||
|
|
||
| // Initialize encryption operation | ||
| int result = EVP_EncryptInit_ex(ctx, EVP_aes_256_cbc(), nullptr, key.data(), iv.data()); | ||
| RELEASE_ASSERT(result == 1, "Encryption initialization failed"); | ||
|
|
||
| // Encrypt the plaintext | ||
| result = EVP_EncryptUpdate(ctx, ciphertext.data(), &len, | ||
| reinterpret_cast<const unsigned char*>(plaintext.c_str()), | ||
| plaintext.size()); | ||
| RELEASE_ASSERT(result == 1, "Encryption update failed"); | ||
|
|
||
| ciphertext_len += len; | ||
|
|
||
| // Finalize encryption | ||
| result = EVP_EncryptFinal_ex(ctx, ciphertext.data() + len, &len); | ||
| RELEASE_ASSERT(result == 1, "Encryption finalization failed"); | ||
|
|
||
| ciphertext_len += len; | ||
|
|
||
| EVP_CIPHER_CTX_free(ctx); | ||
|
|
||
| // AES uses 16-byte IV | ||
| ciphertext.resize(ciphertext_len); | ||
|
|
||
| // Prepend the IV to the ciphertext | ||
| std::vector<unsigned char> combined(iv.size() + ciphertext.size()); | ||
| std::copy(iv.begin(), iv.end(), combined.begin()); | ||
| std::copy(ciphertext.begin(), ciphertext.end(), combined.begin() + iv.size()); | ||
|
|
||
| // Base64Url encode the IV + ciphertext | ||
| return Base64Url::encode(reinterpret_cast<const char*>(combined.data()), combined.size()); | ||
| } | ||
|
|
||
| struct DecryptResult { | ||
| std::string plaintext; | ||
| absl::optional<std::string> error; | ||
| }; | ||
|
|
||
| /** | ||
| * Decrypt an AES-256-CBC encrypted string. | ||
| */ | ||
| DecryptResult decrypt(const std::string& encrypted, const std::string& secret) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hi @zhaohuabing,
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OpenSSL document says that the high level API xref: https://wiki.openssl.org/index.php/OpenSSL_3.0#Low_Level_APIs |
||
| // Decode the Base64Url-encoded input | ||
| std::string decoded = Base64Url::decode(encrypted); | ||
| std::vector<unsigned char> combined(decoded.begin(), decoded.end()); | ||
|
|
||
| if (combined.size() <= 16) { | ||
| return {"", "Invalid encrypted data"}; | ||
| } | ||
|
|
||
| // Extract the IV (first 16 bytes) | ||
| std::vector<unsigned char> iv(combined.begin(), combined.begin() + 16); | ||
|
|
||
| // Extract the ciphertext (remaining bytes) | ||
| std::vector<unsigned char> ciphertext(combined.begin() + 16, combined.end()); | ||
|
|
||
| // Generate the key from the secret using SHA-256 | ||
| std::vector<unsigned char> key(SHA256_DIGEST_LENGTH); | ||
| SHA256(reinterpret_cast<const unsigned char*>(secret.c_str()), secret.size(), key.data()); | ||
|
|
||
| EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new(); | ||
| RELEASE_ASSERT(ctx, "Failed to create context"); | ||
|
|
||
| std::vector<unsigned char> plaintext(ciphertext.size() + EVP_MAX_BLOCK_LENGTH); | ||
| int len = 0, plaintext_len = 0; | ||
|
|
||
| // Initialize decryption operation | ||
| if (EVP_DecryptInit_ex(ctx, EVP_aes_256_cbc(), nullptr, key.data(), iv.data()) != 1) { | ||
| EVP_CIPHER_CTX_free(ctx); | ||
| return {"", "failed to initialize decryption"}; | ||
| } | ||
|
|
||
| // Decrypt the ciphertext | ||
| if (EVP_DecryptUpdate(ctx, plaintext.data(), &len, ciphertext.data(), ciphertext.size()) != 1) { | ||
| EVP_CIPHER_CTX_free(ctx); | ||
| return {"", "failed to decrypt data"}; | ||
| } | ||
| plaintext_len += len; | ||
|
|
||
| // Finalize decryption | ||
| if (EVP_DecryptFinal_ex(ctx, plaintext.data() + len, &len) != 1) { | ||
| EVP_CIPHER_CTX_free(ctx); | ||
| return {"", "failed to finalize decryption"}; | ||
| } | ||
|
|
||
| plaintext_len += len; | ||
|
|
||
| EVP_CIPHER_CTX_free(ctx); | ||
|
|
||
| // Resize to actual plaintext length | ||
| plaintext.resize(plaintext_len); | ||
|
|
||
| return {std::string(plaintext.begin(), plaintext.end()), std::nullopt}; | ||
| } | ||
|
|
||
| } // namespace | ||
|
|
||
| FilterConfig::FilterConfig( | ||
|
|
@@ -518,8 +668,26 @@ Http::FilterHeadersStatus OAuth2Filter::decodeHeaders(Http::RequestHeaderMap& he | |
| Formatter::FormatterImpl::create(config_->redirectUri()), Formatter::FormatterPtr); | ||
| const auto redirect_uri = | ||
| formatter->formatWithContext({&headers}, decoder_callbacks_->streamInfo()); | ||
|
|
||
| std::string encrypted_code_verifier = | ||
| Http::Utility::parseCookieValue(headers, config_->cookieNames().code_verifier_); | ||
| if (encrypted_code_verifier.empty()) { | ||
| ENVOY_LOG(error, "code verifier cookie is missing in the request"); | ||
| sendUnauthorizedResponse(); | ||
| return Http::FilterHeadersStatus::StopIteration; | ||
| } | ||
|
|
||
| DecryptResult decrypt_result = decrypt(encrypted_code_verifier, config_->hmacSecret()); | ||
| if (decrypt_result.error.has_value()) { | ||
| ENVOY_LOG(error, "decryption failed: {}", decrypt_result.error.value()); | ||
| sendUnauthorizedResponse(); | ||
| return Http::FilterHeadersStatus::StopIteration; | ||
| } | ||
|
|
||
| std::string code_verifier = decrypt_result.plaintext; | ||
|
|
||
| oauth_client_->asyncGetAccessToken(auth_code_, config_->clientId(), config_->clientSecret(), | ||
| redirect_uri, config_->authType()); | ||
| redirect_uri, code_verifier, config_->authType()); | ||
|
|
||
| // pause while we await the next step from the OAuth server | ||
| return Http::FilterHeadersStatus::StopAllIterationAndBuffer; | ||
|
|
@@ -580,22 +748,13 @@ void OAuth2Filter::redirectToOAuthServer(Http::RequestHeaderMap& headers) { | |
| // The CSRF token cookie contains the CSRF token that is used to prevent CSRF attacks for the | ||
| // OAuth flow. It was named "oauth_nonce" because the CSRF token contains a generated nonce. | ||
| // "oauth_csrf_token" would be a more accurate name for the cookie. | ||
| std::string csrf_token; | ||
| bool csrf_token_cookie_exists = false; | ||
| const auto csrf_token_cookie = | ||
| Http::Utility::parseCookies(headers, [this](absl::string_view key) { | ||
| return key == config_->cookieNames().oauth_nonce_; | ||
| }); | ||
| if (csrf_token_cookie.find(config_->cookieNames().oauth_nonce_) != csrf_token_cookie.end()) { | ||
| csrf_token = csrf_token_cookie.at(config_->cookieNames().oauth_nonce_); | ||
| csrf_token_cookie_exists = true; | ||
| } else { | ||
| // Generate a CSRF token to prevent CSRF attacks. | ||
| csrf_token = generateCsrfToken(config_->hmacSecret(), random_); | ||
| } | ||
|
|
||
| std::string csrf_token = | ||
| Http::Utility::parseCookieValue(headers, config_->cookieNames().oauth_nonce_); | ||
| bool csrf_token_cookie_exists = !csrf_token.empty(); | ||
| // Set the CSRF token cookie if it does not exist. | ||
| if (!csrf_token_cookie_exists) { | ||
| // Generate a CSRF token to prevent CSRF attacks. | ||
| csrf_token = generateCsrfToken(config_->hmacSecret(), random_); | ||
| // Expire the CSRF token cookie in 10 minutes. | ||
| // This should be enough time for the user to complete the OAuth flow. | ||
| std::string csrf_expires = std::to_string(10 * 60); | ||
|
|
@@ -629,6 +788,30 @@ void OAuth2Filter::redirectToOAuthServer(Http::RequestHeaderMap& headers) { | |
| const std::string escaped_redirect_uri = Http::Utility::PercentEncoding::urlEncode(redirect_uri); | ||
| query_params.overwrite(queryParamsRedirectUri, escaped_redirect_uri); | ||
|
|
||
| // Generate a PKCE code verifier and challenge for the OAuth flow. | ||
| const std::string code_verifier = generateCodeVerifier(random_); | ||
| // Encrypt the code verifier, using HMAC secret as the symmetric key. | ||
| const std::string encrypted_code_verifier = | ||
| encrypt(code_verifier, config_->hmacSecret(), random_); | ||
|
|
||
| // Expire the code verifier cookie in 10 minutes. | ||
| // This should be enough time for the user to complete the OAuth flow. | ||
| std::string expire_in = std::to_string(10 * 60); | ||
| std::string same_site = getSameSiteString(config_->codeVerifierCookieSettings().same_site_); | ||
| std::string cookie_tail_http_only = | ||
| fmt::format(CookieTailHttpOnlyFormatString, expire_in, same_site); | ||
| if (!config_->cookieDomain().empty()) { | ||
| cookie_tail_http_only = absl::StrCat( | ||
| fmt::format(CookieDomainFormatString, config_->cookieDomain()), cookie_tail_http_only); | ||
| } | ||
| response_headers->addReferenceKey(Http::Headers::get().SetCookie, | ||
| absl::StrCat(config_->cookieNames().code_verifier_, "=", | ||
| encrypted_code_verifier, cookie_tail_http_only)); | ||
|
|
||
| const std::string code_challenge = generateCodeChallenge(code_verifier); | ||
| query_params.overwrite(queryParamsCodeChallenge, code_challenge); | ||
| query_params.overwrite(queryParamsCodeChallengeMethod, "S256"); | ||
|
|
||
| // Copy the authorization endpoint URL to replace its query params. | ||
| auto authorization_endpoint_url = config_->authorizationEndpointUrl(); | ||
| const std::string path_and_query_params = query_params.replaceQueryString( | ||
|
|
@@ -676,6 +859,10 @@ Http::FilterHeadersStatus OAuth2Filter::signOutUser(const Http::RequestHeaderMap | |
| Http::Headers::get().SetCookie, | ||
| absl::StrCat(fmt::format(CookieDeleteFormatString, config_->cookieNames().oauth_nonce_), | ||
| cookie_domain)); | ||
| response_headers->addReferenceKey( | ||
| Http::Headers::get().SetCookie, | ||
| absl::StrCat(fmt::format(CookieDeleteFormatString, config_->cookieNames().code_verifier_), | ||
| cookie_domain)); | ||
| response_headers->setLocation(new_path); | ||
| decoder_callbacks_->encodeHeaders(std::move(response_headers), true, SIGN_OUT); | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The encrpt and decrpt method can be also applied to the ID and Access tokens to provide an addtional layer of protection. We can address this in a follow-up PR.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are there already any active development efforts related to this?