This is an automated email from the ASF dual-hosted git repository.

xyz pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pulsar-client-cpp.git


The following commit(s) were added to refs/heads/main by this push:
     new d2cfabd  Support the base64 encoded credentials for OAuth2 
authentication (#249)
d2cfabd is described below

commit d2cfabd3cb158f53605b781699b331c6c535456d
Author: Yunze Xu <xyzinfern...@163.com>
AuthorDate: Mon Apr 17 09:53:20 2023 +0800

    Support the base64 encoded credentials for OAuth2 authentication (#249)
    
    Fixes https://github.com/apache/pulsar-client-python/issues/101
    
    ### Motivation
    
    Currently the `private_key` field of the JSON passed to `AuthOauth2`
    only represents the path to the file, we need to support passing the
    base64 encoded JSON string.
    
    ### Modifications
    
    - Add the util methods `encode` and `decode` in namespace
      `pulsar::base64` for base64 serialization. Then add `Base64Test.cc`
      for it.
    - Support the following URL representations for `private_key`:
      1. `file:///path/to/key/file`
      2. `data:application/json;base64,xxxx`
    - Add `Oauth2Test` and set up the test environment for it independently
      with a Docker compose file.
---
 .github/workflows/ci-pr-validation.yaml |  9 ----
 lib/Base64Utils.h                       | 59 +++++++++++++++++++++
 lib/ProtobufNativeSchema.cc             | 22 ++------
 lib/auth/AuthOauth2.cc                  | 65 ++++++++++++++++++++++-
 lib/auth/AuthOauth2.h                   |  1 +
 run-unit-tests.sh                       | 24 ++++++++-
 test-conf/cpp_credentials_file.json     |  2 +
 tests/Base64Test.cc                     | 38 ++++++++++++++
 tests/CMakeLists.txt                    |  4 ++
 tests/oauth2/Oauth2Test.cc              | 91 +++++++++++++++++++++++++++++++++
 tests/oauth2/docker-compose.yml         | 46 +++++++++++++++++
 11 files changed, 330 insertions(+), 31 deletions(-)

diff --git a/.github/workflows/ci-pr-validation.yaml 
b/.github/workflows/ci-pr-validation.yaml
index 1bb7cb3..a736bb6 100644
--- a/.github/workflows/ci-pr-validation.yaml
+++ b/.github/workflows/ci-pr-validation.yaml
@@ -93,18 +93,9 @@ jobs:
       - name: Build
         run: make -j8
 
-      - name: Start Pulsar service
-        run: ./pulsar-test-service-start.sh
-
-      - name: Run ConnectionFailTest
-        run: ./tests/ConnectionFailTest --gtest_repeat=20
-
       - name: Run unit tests
         run: RETRY_FAILED=3 ./run-unit-tests.sh
 
-      - name: Stop Pulsar service
-        run: ./pulsar-test-service-stop.sh
-
 
   cpp-build-windows:
     timeout-minutes: 120
diff --git a/lib/Base64Utils.h b/lib/Base64Utils.h
new file mode 100644
index 0000000..7706089
--- /dev/null
+++ b/lib/Base64Utils.h
@@ -0,0 +1,59 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+#pragma once
+
+#include <boost/archive/iterators/base64_from_binary.hpp>
+#include <boost/archive/iterators/binary_from_base64.hpp>
+#include <boost/archive/iterators/transform_width.hpp>
+#include <string>
+
+namespace pulsar {
+
+namespace base64 {
+
+inline std::string encode(const char* data, size_t size) {
+    using namespace boost::archive::iterators;
+    using Base64Iter = base64_from_binary<transform_width<const char*, 6, 8>>;
+
+    std::string encoded{Base64Iter(data), Base64Iter(data + size)};
+    const auto numPaddings = (4 - encoded.size()) % 4;
+    encoded.append(numPaddings, '=');
+    return encoded;
+}
+
+template <typename CharContainer>
+inline std::string encode(const CharContainer& container) {
+    return encode(container.data(), container.size());
+}
+
+inline std::string decode(const std::string& encoded) {
+    using namespace boost::archive::iterators;
+    using Base64Iter = 
transform_width<binary_from_base64<std::string::const_iterator>, 8, 6>;
+
+    std::string result{Base64Iter(encoded.cbegin()), 
Base64Iter(encoded.cend())};
+    // There could be '\0's at the tail, it could cause "garbage after data" 
error when parsing JSON
+    while (!result.empty() && result.back() == '\0') {
+        result.pop_back();
+    }
+    return result;
+}
+
+}  // namespace base64
+
+}  // namespace pulsar
diff --git a/lib/ProtobufNativeSchema.cc b/lib/ProtobufNativeSchema.cc
index edae2ec..5cddf74 100644
--- a/lib/ProtobufNativeSchema.cc
+++ b/lib/ProtobufNativeSchema.cc
@@ -20,11 +20,11 @@
 
 #include <google/protobuf/descriptor.pb.h>
 
-#include <boost/archive/iterators/base64_from_binary.hpp>
-#include <boost/archive/iterators/transform_width.hpp>
 #include <stdexcept>
 #include <vector>
 
+#include "Base64Utils.h"
+
 using google::protobuf::FileDescriptor;
 using google::protobuf::FileDescriptorSet;
 
@@ -45,26 +45,10 @@ SchemaInfo createProtobufNativeSchema(const 
google::protobuf::Descriptor* descri
     FileDescriptorSet fileDescriptorSet;
     internalCollectFileDescriptors(fileDescriptor, fileDescriptorSet);
 
-    using namespace boost::archive::iterators;
-    using base64 = base64_from_binary<transform_width<const char*, 6, 8>>;
-
     std::vector<char> bytes(fileDescriptorSet.ByteSizeLong());
     fileDescriptorSet.SerializeToArray(bytes.data(), bytes.size());
 
-    std::string base64String{base64(bytes.data()), base64(bytes.data() + 
bytes.size())};
-    // Pulsar broker only supports decoding Base64 with padding so we need to 
add padding '=' here
-    const size_t numPadding = 4 - base64String.size() % 4;
-    if (numPadding <= 2) {
-        for (size_t i = 0; i < numPadding; i++) {
-            base64String.push_back('=');
-        }
-    } else if (numPadding == 3) {
-        // The length of encoded Base64 string (without padding) should not be 
4N+1
-        throw std::runtime_error("Unexpected padding number (3), the encoded 
Base64 string is:\n" +
-                                 base64String);
-    }  // else numPadding == 4, which means no padding characters need to be 
added
-
-    const std::string schemaJson = R"({"fileDescriptorSet":")" + base64String +
+    const std::string schemaJson = R"({"fileDescriptorSet":")" + 
base64::encode(bytes) +
                                    R"(","rootMessageTypeName":")" + 
rootMessageTypeName +
                                    R"(","rootFileDescriptorName":")" + 
rootFileDescriptorName + R"("})";
 
diff --git a/lib/auth/AuthOauth2.cc b/lib/auth/AuthOauth2.cc
index 1592827..919f2bf 100644
--- a/lib/auth/AuthOauth2.cc
+++ b/lib/auth/AuthOauth2.cc
@@ -26,6 +26,7 @@
 #include <stdexcept>
 
 #include "InitialAuthData.h"
+#include "lib/Base64Utils.h"
 #include "lib/LogUtils.h"
 DECLARE_LOG_OBJECT()
 
@@ -113,10 +114,52 @@ Oauth2Flow::~Oauth2Flow() {}
 
 KeyFile KeyFile::fromParamMap(ParamMap& params) {
     const auto it = params.find("private_key");
-    if (it != params.cend()) {
+    if (it == params.cend()) {
+        return {params["client_id"], params["client_secret"]};
+    }
+
+    const std::string& url = it->second;
+    size_t startPos = 0;
+    auto getPrefix = [&url, &startPos](char separator) -> std::string {
+        const size_t endPos = url.find(separator, startPos);
+        if (endPos == std::string::npos) {
+            return "";
+        }
+        const auto prefix = url.substr(startPos, endPos - startPos);
+        startPos = endPos + 1;
+        return prefix;
+    };
+
+    const auto protocol = getPrefix(':');
+    // If the private key is not a URL, treat it as the file path
+    if (protocol.empty()) {
         return fromFile(it->second);
+    }
+
+    if (protocol == "file") {
+        // URL is "file://..." or "file:..."
+        if (url.size() > startPos + 2 && url[startPos + 1] == '/' && 
url[startPos + 2] == '/') {
+            return fromFile(url.substr(startPos + 2));
+        } else {
+            return fromFile(url.substr(startPos));
+        }
+    } else if (protocol == "data") {
+        // Only support base64 encoded data from a JSON string. The URL should 
be:
+        // "data:application/json;base64,..."
+        const auto contentType = getPrefix(';');
+        if (contentType != "application/json") {
+            LOG_ERROR("Unsupported content type: " << contentType);
+            return {};
+        }
+        const auto encodingType = getPrefix(',');
+        if (encodingType != "base64") {
+            LOG_ERROR("Unsupported encoding type: " << encodingType);
+            return {};
+        }
+        return fromBase64(url.substr(startPos));
     } else {
-        return {params["client_id"], params["client_secret"]};
+        LOG_ERROR("Unsupported protocol: " << protocol);
+        return {};
     }
 }
 
@@ -139,6 +182,24 @@ KeyFile KeyFile::fromFile(const std::string& 
credentialsFilePath) {
     }
 }
 
+KeyFile KeyFile::fromBase64(const std::string& encoded) {
+    boost::property_tree::ptree root;
+    std::stringstream stream;
+    stream << base64::decode(encoded);
+    try {
+        boost::property_tree::read_json(stream, root);
+    } catch (const boost::property_tree::json_parser_error& e) {
+        LOG_ERROR("Failed to parse credentials from " << stream.str());
+        return {};
+    }
+    try {
+        return {root.get<std::string>("client_id"), 
root.get<std::string>("client_secret")};
+    } catch (const boost::property_tree::ptree_error& e) {
+        LOG_ERROR("Failed to get client_id or client_secret in " << 
stream.str() << ": " << e.what());
+        return {};
+    }
+}
+
 ClientCredentialFlow::ClientCredentialFlow(ParamMap& params)
     : issuerUrl_(params["issuer_url"]),
       keyFile_(KeyFile::fromParamMap(params)),
diff --git a/lib/auth/AuthOauth2.h b/lib/auth/AuthOauth2.h
index 31c6122..e65d0e2 100644
--- a/lib/auth/AuthOauth2.h
+++ b/lib/auth/AuthOauth2.h
@@ -48,6 +48,7 @@ class KeyFile {
     KeyFile() : valid_(false) {}
 
     static KeyFile fromFile(const std::string& filename);
+    static KeyFile fromBase64(const std::string& encoded);
 };
 
 class ClientCredentialFlow : public Oauth2Flow {
diff --git a/run-unit-tests.sh b/run-unit-tests.sh
index a5489a7..8be29f0 100755
--- a/run-unit-tests.sh
+++ b/run-unit-tests.sh
@@ -23,7 +23,27 @@ set -e
 ROOT_DIR=$(git rev-parse --show-toplevel)
 cd $ROOT_DIR
 
-pushd tests
+if [[ ! $CMAKE_BUILD_DIRECTORY ]]; then
+    CMAKE_BUILD_DIRECTORY=.
+fi
+
+export http_proxy=
+export https_proxy=
+
+# Run OAuth2 tests
+docker compose -f tests/oauth2/docker-compose.yml up -d
+# Wait until the namespace is created, currently there is no good way to check 
it
+# because it's hard to configure OAuth2 authentication via CLI.
+sleep 15
+$CMAKE_BUILD_DIRECTORY/tests/Oauth2Test
+docker compose -f tests/oauth2/docker-compose.yml down
+
+./pulsar-test-service-start.sh
+
+pushd $CMAKE_BUILD_DIRECTORY/tests
+
+# Avoid this test is still flaky, see 
https://github.com/apache/pulsar-client-cpp/pull/217
+./ConnectionFailTest --gtest_repeat=20
 
 export RETRY_FAILED="${RETRY_FAILED:-1}"
 
@@ -54,4 +74,6 @@ fi
 
 popd
 
+./pulsar-test-service-stop.sh
+
 exit $RES
diff --git a/test-conf/cpp_credentials_file.json 
b/test-conf/cpp_credentials_file.json
index db1eccd..cbc7e81 100644
--- a/test-conf/cpp_credentials_file.json
+++ b/test-conf/cpp_credentials_file.json
@@ -1,4 +1,6 @@
 {
+  "issuer_url": "https://dev-kt-aa9ne.us.auth0.com";,
+  "audience": "https://dev-kt-aa9ne.us.auth0.com/api/v2/";,
   "client_id":"Xd23RHsUnvUlP7wchjNYOaIfazgeHd9x",
   
"client_secret":"rT7ps7WY8uhdVuBTKWZkttwLdQotmdEliaM5rLfmgNibvqziZ-g07ZH52N_poGAb"
 }
diff --git a/tests/Base64Test.cc b/tests/Base64Test.cc
new file mode 100644
index 0000000..487dc08
--- /dev/null
+++ b/tests/Base64Test.cc
@@ -0,0 +1,38 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+#include <gtest/gtest.h>
+
+#include <cstring>
+
+#include "lib/Base64Utils.h"
+
+using namespace pulsar;
+
+TEST(Base64Test, testJsonEncodeDecode) {
+    const std::string s1 = R"("{"key":"value"}")";
+    const auto s2 = base64::decode(base64::encode(s1));
+    ASSERT_EQ(s1, s2);
+}
+
+TEST(Base64Test, testPaddings) {
+    auto encode = [](const char* s) { return base64::encode(s, strlen(s)); };
+    ASSERT_EQ(encode("x"), "eA==");    // 2 paddings
+    ASSERT_EQ(encode("xy"), "eHk=");   // 1 padding
+    ASSERT_EQ(encode("xyz"), "eHl6");  // 0 padding
+}
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 05ca139..cfc9e27 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -64,3 +64,7 @@ if (UNIX)
     add_executable(ConnectionFailTest unix/ConnectionFailTest.cc HttpHelper.cc)
     target_link_libraries(ConnectionFailTest ${CLIENT_LIBS} pulsarStatic 
${GTEST_LIBRARY_PATH})
 endif ()
+
+add_executable(Oauth2Test oauth2/Oauth2Test.cc)
+target_compile_options(Oauth2Test PRIVATE 
"-DTEST_ROOT_PATH=\"${CMAKE_CURRENT_SOURCE_DIR}\"")
+target_link_libraries(Oauth2Test ${CLIENT_LIBS} pulsarStatic 
${GTEST_LIBRARY_PATH})
diff --git a/tests/oauth2/Oauth2Test.cc b/tests/oauth2/Oauth2Test.cc
new file mode 100644
index 0000000..6264620
--- /dev/null
+++ b/tests/oauth2/Oauth2Test.cc
@@ -0,0 +1,91 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+// Run `docker-compose up -d` to set up the test environment for this test.
+#include <gtest/gtest.h>
+#include <pulsar/Client.h>
+
+#include <boost/property_tree/json_parser.hpp>
+#include <boost/property_tree/ptree.hpp>
+
+#include "lib/Base64Utils.h"
+
+using namespace pulsar;
+
+#ifndef TEST_ROOT_PATH
+#define TEST_ROOT_PATH "."
+#endif
+
+static const std::string gKeyPath = std::string(TEST_ROOT_PATH) + 
"/../test-conf/cpp_credentials_file.json";
+static std::string gClientId;
+static std::string gClientSecret;
+static ParamMap gCommonParams;
+
+static Result testCreateProducer(const std::string& privateKey);
+
+static std::string credentials(const std::string& clientId, const std::string& 
clientSecret) {
+    return base64::encode(R"({"client_id":")" + clientId + 
R"(","client_secret":")" + clientSecret + R"("})");
+}
+
+TEST(Oauth2Test, testBase64Key) {
+    ASSERT_EQ(ResultOk,
+              testCreateProducer("data:application/json;base64," + 
credentials(gClientId, gClientSecret)));
+    ASSERT_EQ(ResultAuthenticationError,
+              testCreateProducer("data:application/json;base64," + 
credentials("test-id", "test-secret")));
+}
+
+TEST(Oauth2Test, testFileKey) {
+    ASSERT_EQ(ResultOk, testCreateProducer("file://" + gKeyPath));
+    ASSERT_EQ(ResultOk, testCreateProducer("file:" + gKeyPath));
+    ASSERT_EQ(ResultOk, testCreateProducer(gKeyPath));
+    ASSERT_EQ(ResultAuthenticationError, 
testCreateProducer("file:///tmp/file-not-exist"));
+}
+
+TEST(Oauth2Test, testWrongUrl) {
+    ASSERT_EQ(ResultAuthenticationError,
+              testCreateProducer("data:text/plain;base64," + 
credentials(gClientId, gClientSecret)));
+    ASSERT_EQ(ResultAuthenticationError,
+              testCreateProducer("data:application/json;text," + 
credentials(gClientId, gClientSecret)));
+    ASSERT_EQ(ResultAuthenticationError, testCreateProducer("my-protocol:" + 
gKeyPath));
+}
+
+int main(int argc, char* argv[]) {
+    std::cout << "Load Oauth2 configs from " << gKeyPath << "..." << std::endl;
+    boost::property_tree::ptree root;
+    boost::property_tree::read_json(gKeyPath, root);
+    gClientId = root.get<std::string>("client_id");
+    gClientSecret = root.get<std::string>("client_secret");
+    gCommonParams["issuer_url"] = root.get<std::string>("issuer_url");
+    gCommonParams["audience"] = root.get<std::string>("audience");
+
+    ::testing::InitGoogleTest(&argc, argv);
+    return RUN_ALL_TESTS();
+    return 0;
+}
+
+static Result testCreateProducer(const std::string& privateKey) {
+    ClientConfiguration conf;
+    auto params = gCommonParams;
+    params["private_key"] = privateKey;
+    conf.setAuth(AuthOauth2::create(params));
+    Client client{"pulsar://localhost:6650", conf};
+    Producer producer;
+    const auto result = client.createProducer("oauth2-test", producer);
+    client.close();
+    return result;
+}
diff --git a/tests/oauth2/docker-compose.yml b/tests/oauth2/docker-compose.yml
new file mode 100644
index 0000000..0ab818e
--- /dev/null
+++ b/tests/oauth2/docker-compose.yml
@@ -0,0 +1,46 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+version: '3'
+networks:
+  pulsar:
+    driver: bridge
+services:
+  standalone:
+    image: apachepulsar/pulsar:latest
+    container_name: standalone
+    hostname: local
+    restart: "no"
+    networks:
+      - pulsar
+    environment:
+      - metadataStoreUrl=zk:localhost:2181
+      - clusterName=standalone-oauth2
+      - advertisedAddress=localhost
+      - advertisedListeners=external:pulsar://localhost:6650
+      - PULSAR_MEM=-Xms512m -Xmx512m -XX:MaxDirectMemorySize=256m
+      - PULSAR_PREFIX_authenticationEnabled=true
+      - 
PULSAR_PREFIX_authenticationProviders=org.apache.pulsar.broker.authentication.AuthenticationProviderToken
+      - 
PULSAR_PREFIX_tokenPublicKey=data:;base64,MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2tZd/4gJda3U2Pc3tpgRAN7JPGWx/Gn17v/0IiZlNNRbP/Mmf0Vc6G1qsnaRaWNWOR+t6/a6ekFHJMikQ1N2X6yfz4UjMc8/G2FDPRmWjA+GURzARjVhxc/BBEYGoD0Kwvbq/u9CZm2QjlKrYaLfg3AeB09j0btNrDJ8rBsNzU6AuzChRvXj9IdcE/A/4N/UQ+S9cJ4UXP6NJbToLwajQ5km+CnxdGE6nfB7LWHvOFHjn9C2Rb9e37CFlmeKmIVFkagFM0gbmGOb6bnGI8Bp/VNGV0APef4YaBvBTqwoZ1Z4aDHy5eRxXfAMdtBkBupmBXqL6bpd15XRYUbu/7ck9QIDAQAB
+      - 
PULSAR_PREFIX_brokerClientAuthenticationPlugin=org.apache.pulsar.client.impl.auth.oauth2.AuthenticationOAuth2
+      - 
PULSAR_PREFIX_brokerClientAuthenticationParameters={"issuerUrl":"https://dev-kt-aa9ne.us.auth0.com","audience":"https://dev-kt-aa9ne.us.auth0.com/api/v2/","privateKey":"data:application/json;base64,ewogICAgICAgICAgICAiY2xpZW50X2lkIjoiWGQyM1JIc1VudlVsUDd3Y2hqTllPYUlmYXpnZUhkOXgiLAogICAgICAgICAgICAiY2xpZW50X3NlY3JldCI6InJUN3BzN1dZOHVoZFZ1QlRLV1prdHR3TGRRb3RtZEVsaWFNNXJMZm1nTmlidnF6aVotZzA3Wkg1Mk5fcG9HQWIiCiAgICAgICAgfQ=="}
+    ports:
+      - "6650:6650"
+      - "8080:8080"
+    command: bash -c "bin/apply-config-from-env.py conf/standalone.conf && 
exec bin/pulsar standalone -nss -nfw"

Reply via email to