From 562d9d421f0c10bf2d7a95a3c9fab9da543c6cda Mon Sep 17 00:00:00 2001 From: Alan George Date: Wed, 17 Jun 2026 18:06:19 -0600 Subject: [PATCH 1/4] Initial token source API --- README.md | 26 +++++++++++ include/livekit/room.h | 21 +++++++++ include/livekit/room_delegate.h | 3 ++ include/livekit/room_event_types.h | 9 ++++ src/room.cpp | 32 +++++++++++++ src/room_proto_converter.cpp | 6 +++ src/room_proto_converter.h | 1 + src/tests/unit/test_room.cpp | 59 ++++++++++++++++++++++++ src/tests/unit/test_room_event_types.cpp | 13 ++++++ 9 files changed, 170 insertions(+) diff --git a/README.md b/README.md index d9fd9d71..c6b2ae9c 100644 --- a/README.md +++ b/README.md @@ -187,9 +187,35 @@ room->addOnDataFrameCallback(sender_identity, "app-data", For end-to-end samples and a fuller set of demos, see the [cpp-example-collection repo](https://github.com/livekit-examples/cpp-example-collection). +### Token source (dynamic tokens) + +When tokens are minted by your backend at connect time, pass an async callback +instead of a static JWT string: + +```cpp +#include + +livekit::TokenSource token_source = []() -> std::future { + std::promise promise; + promise.set_value(fetch_token_from_backend()); // your HTTP/auth logic + return promise.get_future(); +}; + +if (!room->connect(url, token_source, options)) { + std::cerr << "Failed to connect to LiveKit\n"; + return 1; +} +``` + +The callback runs on the application thread and `connect` blocks until the +future completes. During an active session the SDK refreshes tokens internally +for reconnect; override `RoomDelegate::onTokenRefreshed` if you want to log or +cache the latest token. + ## Features - Connect to LiveKit rooms (Cloud or self-hosted) +- Dynamic token sourcing via async callback at connect time - Receive remote audio/video tracks - Publish local audio/video tracks - Data tracks (low-level) and data streams (high-level) diff --git a/include/livekit/room.h b/include/livekit/room.h index be76653f..986ba4f5 100644 --- a/include/livekit/room.h +++ b/include/livekit/room.h @@ -18,6 +18,7 @@ #include #include +#include #include #include #include @@ -123,6 +124,12 @@ struct RoomOptions { std::optional connect_timeout; }; +/// Async callback that supplies an access token for room connection. +/// +/// Invoked on the application thread during @ref Room::connect before the FFI +/// connect request is sent. The returned future must resolve to a non-empty JWT. +using TokenSource = std::function()>; + /// Represents a LiveKit room session. /// A Room manages: /// - the connection to the LiveKit server @@ -165,6 +172,20 @@ class LIVEKIT_API Room { /// automatically, and no remote audio/video will ever arrive. bool connect(const std::string& url, const std::string& token, const RoomOptions& options); + /// Connect to a LiveKit room using the given URL and token source. + /// + /// @param url WebSocket URL of the LiveKit server. + /// @param token_source Async callback that fetches an access token. + /// @param options Connection options controlling auto-subscribe, + /// dynacast, E2EE, and WebRTC configuration. + /// @return @c false if the token source is empty, fails, returns an empty + /// token, or the underlying connect fails. + /// + /// The token source is invoked on the application thread and @ref connect + /// blocks until the future completes. Use this overload when tokens are + /// fetched from your own backend rather than supplied as a static string. + bool connect(const std::string& url, const TokenSource& token_source, const RoomOptions& options); + /// Disconnect from the room. /// /// This method attempts a best-effort graceful disconnect of the room. If the room was connected prior, after @ref diff --git a/include/livekit/room_delegate.h b/include/livekit/room_delegate.h index 7902ad09..3ecd8d67 100644 --- a/include/livekit/room_delegate.h +++ b/include/livekit/room_delegate.h @@ -140,6 +140,9 @@ class LIVEKIT_API RoomDelegate { /// Called after the SDK successfully reconnects. virtual void onReconnected(Room&, const ReconnectedEvent&) {} + /// Called when the server refreshes the session access token. + virtual void onTokenRefreshed(Room&, const TokenRefreshedEvent&) {} + // ------------------------------------------------------------------ // E2EE // ------------------------------------------------------------------ diff --git a/include/livekit/room_event_types.h b/include/livekit/room_event_types.h index cb55f7b0..88e02818 100644 --- a/include/livekit/room_event_types.h +++ b/include/livekit/room_event_types.h @@ -545,6 +545,15 @@ struct ReconnectingEvent {}; /// Fired after successfully reconnecting. struct ReconnectedEvent {}; +/// Fired when the server refreshes the session access token. +/// +/// The SDK applies the refreshed token internally for reconnect; this event is +/// informational so applications can log or cache the latest token. +struct TokenRefreshedEvent { + /// Refreshed access token. + std::string token; +}; + /// Fired when the room has reached end-of-stream (no more events). struct RoomEosEvent {}; diff --git a/src/room.cpp b/src/room.cpp index 3ad58938..eb1e934a 100644 --- a/src/room.cpp +++ b/src/room.cpp @@ -91,6 +91,31 @@ void Room::setDelegate(RoomDelegate* delegate) { delegate_ = delegate; } +bool Room::connect(const std::string& url, const TokenSource& token_source, const RoomOptions& options) { + if (!token_source) { + LK_LOG_ERROR("Room::connect failed: token source is empty"); + return false; + } + + std::string token; + try { + token = token_source().get(); + } catch (const std::exception& e) { + LK_LOG_ERROR("Room::connect failed: token source threw: {}", e.what()); + return false; + } catch (...) { + LK_LOG_ERROR("Room::connect failed: token source threw unknown exception"); + return false; + } + + if (token.empty()) { + LK_LOG_ERROR("Room::connect failed: token source returned empty token"); + return false; + } + + return connect(url, token, options); +} + bool Room::connect(const std::string& url, const std::string& token, const RoomOptions& options) { TRACE_EVENT0("livekit", "Room::connect"); @@ -1202,6 +1227,13 @@ void Room::onEvent(const FfiEvent& event) { } break; } + case proto::RoomEvent::kTokenRefreshed: { + const TokenRefreshedEvent ev = fromProto(re.token_refreshed()); + if (delegate_snapshot) { + delegate_snapshot->onTokenRefreshed(*this, ev); + } + break; + } case proto::RoomEvent::kEos: { if (subscription_thread_dispatcher_) { subscription_thread_dispatcher_->stopAll(); diff --git a/src/room_proto_converter.cpp b/src/room_proto_converter.cpp index 53b49c59..59561592 100644 --- a/src/room_proto_converter.cpp +++ b/src/room_proto_converter.cpp @@ -323,6 +323,12 @@ ReconnectingEvent fromProto(const proto::Reconnecting& /*in*/) { return Reconnec ReconnectedEvent fromProto(const proto::Reconnected& /*in*/) { return ReconnectedEvent{}; } +TokenRefreshedEvent fromProto(const proto::TokenRefreshed& in) { + TokenRefreshedEvent ev; + ev.token = in.token(); + return ev; +} + RoomEosEvent fromProto(const proto::RoomEOS& /*in*/) { return RoomEosEvent{}; } DataStreamHeaderReceivedEvent fromProto(const proto::DataStreamHeaderReceived& in) { diff --git a/src/room_proto_converter.h b/src/room_proto_converter.h index 2189097c..b834d262 100644 --- a/src/room_proto_converter.h +++ b/src/room_proto_converter.h @@ -56,6 +56,7 @@ LIVEKIT_INTERNAL_API ConnectionStateChangedEvent fromProto(const proto::Connecti LIVEKIT_INTERNAL_API DisconnectedEvent fromProto(const proto::Disconnected& in); LIVEKIT_INTERNAL_API ReconnectingEvent fromProto(const proto::Reconnecting& in); LIVEKIT_INTERNAL_API ReconnectedEvent fromProto(const proto::Reconnected& in); +LIVEKIT_INTERNAL_API TokenRefreshedEvent fromProto(const proto::TokenRefreshed& in); LIVEKIT_INTERNAL_API RoomEosEvent fromProto(const proto::RoomEOS& in); LIVEKIT_INTERNAL_API DataStreamHeaderReceivedEvent fromProto(const proto::DataStreamHeaderReceived& in); diff --git a/src/tests/unit/test_room.cpp b/src/tests/unit/test_room.cpp index 7a9ad193..15b3d1e1 100644 --- a/src/tests/unit/test_room.cpp +++ b/src/tests/unit/test_room.cpp @@ -18,6 +18,7 @@ #include #include +#include #include #include "ffi.pb.h" @@ -45,6 +46,64 @@ TEST_F(RoomTest, ConnectWithoutInitialize) { EXPECT_TRUE(room.remoteParticipants().empty()) << "Remote participants should be empty after failed connect"; } +TEST_F(RoomTest, ConnectWithEmptyTokenSourceFails) { + Room room; + + const bool result = room.connect("wss://localhost:7880", livekit::TokenSource{}, livekit::RoomOptions()); + EXPECT_FALSE(result) << "Connecting with an empty token source should return false"; +} + +TEST_F(RoomTest, ConnectWithTokenSourceReturningEmptyFails) { + Room room; + + livekit::TokenSource source = []() -> std::future { + std::promise promise; + promise.set_value(""); + return promise.get_future(); + }; + + const bool result = room.connect("wss://localhost:7880", source, livekit::RoomOptions()); + EXPECT_FALSE(result) << "Connecting with an empty token should return false"; +} + +TEST_F(RoomTest, ConnectWithTokenSourceThrowingFails) { + Room room; + + livekit::TokenSource source = []() -> std::future { + std::promise promise; + promise.set_exception(std::make_exception_ptr(std::runtime_error("token fetch failed"))); + return promise.get_future(); + }; + + const bool result = room.connect("wss://localhost:7880", source, livekit::RoomOptions()); + EXPECT_FALSE(result) << "Connecting when token source throws should return false"; +} + +TEST_F(RoomTest, ConnectWithTokenSourceInvokesCallbackBeforeConnectFailure) { + livekit::shutdown(); + + Room room; + int call_count = 0; + livekit::TokenSource source = [&call_count]() -> std::future { + ++call_count; + std::promise promise; + promise.set_value("fetched-token"); + return promise.get_future(); + }; + + const bool result = room.connect("wss://localhost:7880", source, livekit::RoomOptions()); + EXPECT_FALSE(result) << "Connecting without initializing should return false"; + EXPECT_EQ(call_count, 1) << "Token source should be invoked once before connect fails"; +} + +TEST(RoomOptionsProtoTest, TokenRefreshedFromProto) { + proto::TokenRefreshed refreshed; + refreshed.set_token("refreshed-jwt"); + + const livekit::TokenRefreshedEvent event = livekit::fromProto(refreshed); + EXPECT_EQ(event.token, "refreshed-jwt"); +} + TEST_F(RoomTest, CreateRoom) { Room room; // Room should be created without issues diff --git a/src/tests/unit/test_room_event_types.cpp b/src/tests/unit/test_room_event_types.cpp index db423142..5fbbff90 100644 --- a/src/tests/unit/test_room_event_types.cpp +++ b/src/tests/unit/test_room_event_types.cpp @@ -17,6 +17,8 @@ #include #include +#include + namespace livekit::test { TEST(RoomEventTypesTest, EnumValuesAreReachable) { @@ -53,4 +55,15 @@ TEST(RoomEventTypesTest, UserPacketDataDefaults) { EXPECT_FALSE(packet.topic.has_value()); } +TEST(RoomEventTypesTest, TokenRefreshedEventDefaults) { + TokenRefreshedEvent event; + EXPECT_TRUE(event.token.empty()); +} + +TEST(RoomEventTypesTest, TokenRefreshedEventStoresToken) { + TokenRefreshedEvent event; + event.token = "refreshed-jwt"; + EXPECT_EQ(event.token, "refreshed-jwt"); +} + } // namespace livekit::test From 05295dd9f918f90c80037c28767b5d0670560b1d Mon Sep 17 00:00:00 2001 From: Alan George Date: Wed, 17 Jun 2026 18:09:16 -0600 Subject: [PATCH 2/4] Add integration test --- src/tests/integration/test_room.cpp | 43 +++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/tests/integration/test_room.cpp b/src/tests/integration/test_room.cpp index 145d709d..f9a2ff51 100644 --- a/src/tests/integration/test_room.cpp +++ b/src/tests/integration/test_room.cpp @@ -17,6 +17,7 @@ #include #include +#include #include #include #include @@ -49,6 +50,10 @@ class RoomTest : public ::testing::Test { }; TEST_F(RoomTest, ConnectToServer) { + if (!server_available_) { + GTEST_SKIP() << "LIVEKIT_URL and LIVEKIT_TOKEN_A not set"; + } + Room room; RoomOptions options; @@ -60,7 +65,37 @@ TEST_F(RoomTest, ConnectToServer) { } } +TEST_F(RoomTest, ConnectWithTokenSource) { + if (!server_available_) { + GTEST_SKIP() << "LIVEKIT_URL and LIVEKIT_TOKEN_A not set"; + } + + Room room; + RoomOptions options; + + int fetch_count = 0; + const TokenSource token_source = [this, &fetch_count]() -> std::future { + ++fetch_count; + std::promise promise; + promise.set_value(token_); + return promise.get_future(); + }; + + const bool connected = room.connect(server_url_, token_source, options); + EXPECT_TRUE(connected) << "Should connect to server via token source"; + EXPECT_EQ(fetch_count, 1) << "Token source should be invoked exactly once"; + + if (connected) { + EXPECT_FALSE(room.localParticipant().expired()) << "Local participant should exist after connect"; + EXPECT_EQ(room.connectionState(), ConnectionState::Connected); + } +} + TEST_F(RoomTest, ConnectWithInvalidToken) { + if (!server_available_) { + GTEST_SKIP() << "LIVEKIT_URL and LIVEKIT_TOKEN_A not set"; + } + Room room; RoomOptions options; @@ -93,6 +128,10 @@ class DisconnectTrackingDelegate : public RoomDelegate { // Case: User calls disconnect() TEST_F(RoomTest, UserDisconnect) { + if (!server_available_) { + GTEST_SKIP() << "LIVEKIT_URL and LIVEKIT_TOKEN_A not set"; + } + Room room; DisconnectTrackingDelegate delegate; room.setDelegate(&delegate); @@ -115,6 +154,10 @@ TEST_F(RoomTest, UserDisconnect) { // Case: Room goes out of scope while still connected TEST_F(RoomTest, DestructorDisconnect) { + if (!server_available_) { + GTEST_SKIP() << "LIVEKIT_URL and LIVEKIT_TOKEN_A not set"; + } + std::unique_ptr room = std::make_unique(); DisconnectTrackingDelegate delegate; From 2ec2781aa87a7d03b698056494674a78a31562d2 Mon Sep 17 00:00:00 2001 From: Alan George Date: Thu, 18 Jun 2026 09:47:33 -0600 Subject: [PATCH 3/4] Actual TokenSource logic --- .github/workflows/builds.yml | 1 + .github/workflows/cpp-checks.yml | 2 +- .github/workflows/make-release.yml | 1 + .github/workflows/tests.yml | 1 + CMakeLists.txt | 12 ++ README.md | 53 +++++-- include/livekit/livekit.h | 1 + include/livekit/room.h | 37 ++--- include/livekit/token_source.h | 195 ++++++++++++++++++++++++ src/room.cpp | 47 +++++- src/tests/integration/test_room.cpp | 47 ++++-- src/tests/unit/test_room.cpp | 85 +++++++---- src/tests/unit/test_token_source.cpp | 151 +++++++++++++++++++ src/token_source.cpp | 153 +++++++++++++++++++ src/token_source_http.cpp | 217 +++++++++++++++++++++++++++ src/token_source_internal.h | 42 ++++++ src/token_source_json.cpp | 199 ++++++++++++++++++++++++ src/token_source_jwt.cpp | 161 ++++++++++++++++++++ 18 files changed, 1324 insertions(+), 81 deletions(-) create mode 100644 include/livekit/token_source.h create mode 100644 src/tests/unit/test_token_source.cpp create mode 100644 src/token_source.cpp create mode 100644 src/token_source_http.cpp create mode 100644 src/token_source_internal.h create mode 100644 src/token_source_json.cpp create mode 100644 src/token_source_jwt.cpp diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml index 9f6436fd..a614da1f 100644 --- a/.github/workflows/builds.yml +++ b/.github/workflows/builds.yml @@ -114,6 +114,7 @@ jobs: libssl-dev \ libprotobuf-dev protobuf-compiler \ libabsl-dev \ + libcurl4-openssl-dev \ libwayland-dev libdecor-0-dev - name: Install deps (macOS) diff --git a/.github/workflows/cpp-checks.yml b/.github/workflows/cpp-checks.yml index 66568719..cabb6a52 100644 --- a/.github/workflows/cpp-checks.yml +++ b/.github/workflows/cpp-checks.yml @@ -62,7 +62,7 @@ jobs: sudo apt-get install -y \ build-essential cmake ninja-build pkg-config \ llvm-dev libclang-dev clang \ - libssl-dev wget ca-certificates gnupg + libssl-dev libcurl4-openssl-dev wget ca-certificates gnupg - name: Install clang-tidy 19 (for ExcludeHeaderFilterRegex support) run: | diff --git a/.github/workflows/make-release.yml b/.github/workflows/make-release.yml index 81bc49a7..fad0e9ba 100644 --- a/.github/workflows/make-release.yml +++ b/.github/workflows/make-release.yml @@ -116,6 +116,7 @@ jobs: libssl-dev \ libprotobuf-dev protobuf-compiler \ libabsl-dev \ + libcurl4-openssl-dev \ libwayland-dev libdecor-0-dev - name: Install deps (macOS) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f4c29b4e..21922020 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -114,6 +114,7 @@ jobs: libssl-dev \ libprotobuf-dev protobuf-compiler \ libabsl-dev \ + libcurl4-openssl-dev \ libwayland-dev libdecor-0-dev \ jq diff --git a/CMakeLists.txt b/CMakeLists.txt index be4a8e5d..fcc22396 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -392,6 +392,11 @@ add_library(livekit SHARED src/room_proto_converter.cpp src/room_proto_converter.h src/subscription_thread_dispatcher.cpp + src/token_source.cpp + src/token_source_http.cpp + src/token_source_json.cpp + src/token_source_jwt.cpp + src/token_source_internal.h src/local_participant.cpp src/remote_participant.cpp src/stats.cpp @@ -461,6 +466,13 @@ target_link_libraries(livekit ${LIVEKIT_PROTOBUF_TARGET} ) +if(WIN32) + target_link_libraries(livekit PRIVATE winhttp) +else() + find_package(CURL REQUIRED) + target_link_libraries(livekit PRIVATE CURL::libcurl) +endif() + target_compile_definitions(livekit PRIVATE SPDLOG_ACTIVE_LEVEL=${_SPDLOG_ACTIVE_LEVEL} diff --git a/README.md b/README.md index c6b2ae9c..89198e40 100644 --- a/README.md +++ b/README.md @@ -189,33 +189,58 @@ For end-to-end samples and a fuller set of demos, see the [cpp-example-collectio ### Token source (dynamic tokens) -When tokens are minted by your backend at connect time, pass an async callback -instead of a static JWT string: +The SDK provides token sources for literal credentials, custom async logic, +HTTP token-server endpoints, LiveKit Cloud sandbox (dev), and JWT-aware caching. +See the [token server docs](https://docs.livekit.io/frontends/build/authentication/). + +**Literal** — static URL + JWT: ```cpp -#include +#include -livekit::TokenSource token_source = []() -> std::future { - std::promise promise; - promise.set_value(fetch_token_from_backend()); // your HTTP/auth logic - return promise.get_future(); -}; +livekit::ConnectionDetails details; +details.server_url = url; +details.participant_token = jwt; -if (!room->connect(url, token_source, options)) { +auto source = livekit::LiteralTokenSource::fromDetails(std::move(details)); +if (!room->connect(*source, options)) { std::cerr << "Failed to connect to LiveKit\n"; return 1; } ``` -The callback runs on the application thread and `connect` blocks until the -future completes. During an active session the SDK refreshes tokens internally -for reconnect; override `RoomDelegate::onTokenRefreshed` if you want to log or -cache the latest token. +**Endpoint** — POST to your token server: + +```cpp +livekit::TokenRequestOptions request; +request.room_name = "my-room"; +request.participant_identity = "user-123"; + +auto source = livekit::EndpointTokenSource::fromUrl("https://your-backend.example.com/token"); +if (!room->connect(*source, request, options)) { + return 1; +} +``` + +**Custom** — wrap your own fetch logic: + +```cpp +auto source = livekit::CustomTokenSource::fromCallback([](const livekit::TokenRequestOptions& options) { + std::promise> promise; + // fetch from your backend, then: + promise.set_value(livekit::Result::success(details)); + return promise.get_future(); +}); +``` + +During an active session the SDK refreshes tokens internally for reconnect. +`Room::participantToken()` returns the latest JWT and +`RoomDelegate::onTokenRefreshed` fires when it changes. ## Features - Connect to LiveKit rooms (Cloud or self-hosted) -- Dynamic token sourcing via async callback at connect time +- Dynamic token sourcing (literal, custom, endpoint, sandbox, caching) - Receive remote audio/video tracks - Publish local audio/video tracks - Data tracks (low-level) and data streams (high-level) diff --git a/include/livekit/livekit.h b/include/livekit/livekit.h index 4abcb2d5..d0aaee89 100644 --- a/include/livekit/livekit.h +++ b/include/livekit/livekit.h @@ -34,6 +34,7 @@ #include "livekit/room.h" #include "livekit/room_delegate.h" #include "livekit/room_event_types.h" +#include "livekit/token_source.h" #include "livekit/tracing.h" #include "livekit/track_publication.h" #include "livekit/video_frame.h" diff --git a/include/livekit/room.h b/include/livekit/room.h index 986ba4f5..197c516d 100644 --- a/include/livekit/room.h +++ b/include/livekit/room.h @@ -29,6 +29,7 @@ #include "livekit/room_event_types.h" #include "livekit/stats.h" #include "livekit/subscription_thread_dispatcher.h" +#include "livekit/token_source.h" #include "livekit/visibility.h" namespace livekit { @@ -124,12 +125,6 @@ struct RoomOptions { std::optional connect_timeout; }; -/// Async callback that supplies an access token for room connection. -/// -/// Invoked on the application thread during @ref Room::connect before the FFI -/// connect request is sent. The returned future must resolve to a non-empty JWT. -using TokenSource = std::function()>; - /// Represents a LiveKit room session. /// A Room manages: /// - the connection to the LiveKit server @@ -172,19 +167,21 @@ class LIVEKIT_API Room { /// automatically, and no remote audio/video will ever arrive. bool connect(const std::string& url, const std::string& token, const RoomOptions& options); - /// Connect to a LiveKit room using the given URL and token source. + /// Connect using a fixed token source that supplies server URL and JWT. /// - /// @param url WebSocket URL of the LiveKit server. - /// @param token_source Async callback that fetches an access token. - /// @param options Connection options controlling auto-subscribe, - /// dynacast, E2EE, and WebRTC configuration. - /// @return @c false if the token source is empty, fails, returns an empty - /// token, or the underlying connect fails. + /// @param token_source Token source invoked on the application thread. + /// @param options Connection options. + /// @return @c false if fetching credentials fails or connect fails. + bool connect(TokenSourceFixed& token_source, const RoomOptions& options); + + /// Connect using a configurable token source. /// - /// The token source is invoked on the application thread and @ref connect - /// blocks until the future completes. Use this overload when tokens are - /// fetched from your own backend rather than supplied as a static string. - bool connect(const std::string& url, const TokenSource& token_source, const RoomOptions& options); + /// @param token_source Token source invoked on the application thread. + /// @param request_options Parameters encoded into the token request. + /// @param options Connection options. + /// @return @c false if fetching credentials fails or connect fails. + bool connect(TokenSourceConfigurable& token_source, const TokenRequestOptions& request_options, + const RoomOptions& options); /// Disconnect from the room. /// @@ -251,6 +248,11 @@ class LIVEKIT_API Room { /// Returns the current connection state of the room. ConnectionState connectionState() const; + /// Returns the participant JWT from the last successful connect or token refresh. + /// + /// Empty when the room has never connected or after disconnect. + std::string participantToken() const; + /// Retrieve aggregated WebRTC stats for this room session. /// /// Dispatches an async request to the server and returns a future that @@ -355,6 +357,7 @@ class LIVEKIT_API Room { mutable std::mutex lock_; ConnectionState connection_state_ = ConnectionState::Disconnected; + std::string participant_token_; RoomDelegate* delegate_ = nullptr; // Not owned RoomInfoData room_info_; std::shared_ptr room_handle_; diff --git a/include/livekit/token_source.h b/include/livekit/token_source.h new file mode 100644 index 00000000..4b8a9045 --- /dev/null +++ b/include/livekit/token_source.h @@ -0,0 +1,195 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed 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 License governing permissions and limitations. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "livekit/result.h" +#include "livekit/visibility.h" + +namespace livekit { + +/// @brief Credentials returned by a @ref TokenSourceFixed or @ref TokenSourceConfigurable. +struct ConnectionDetails { + /// WebSocket URL of the LiveKit server. + std::string server_url; + + /// JWT access token for the participant. + std::string participant_token; + + /// Optional participant display name returned by the token server. + std::optional participant_name; + + /// Optional room name returned by the token server. + std::optional room_name; +}; + +/// @brief Per-call options sent to configurable token sources (endpoint, sandbox, custom). +struct TokenRequestOptions { + std::optional room_name; + std::optional participant_name; + std::optional participant_identity; + std::optional participant_metadata; + std::map participant_attributes; + std::optional agent_name; + std::optional agent_metadata; + std::optional agent_deployment; +}; + +/// @brief HTTP options for @ref EndpointTokenSource. +struct TokenEndpointOptions { + /// HTTP method (default @c POST). + std::string method = "POST"; + + /// Additional request headers. + std::map headers; + + /// Request timeout (default 30 seconds). + std::chrono::milliseconds timeout = std::chrono::seconds(30); +}; + +/// @brief Error returned when token fetching fails. +struct TokenSourceError { + std::string message; +}; + +/// @brief Fixed token source: @ref fetch takes no parameters. +class LIVEKIT_API TokenSourceFixed { +public: + virtual ~TokenSourceFixed(); + + /// Fetch connection credentials. + /// + /// @return Future resolving to connection details or an error. + virtual std::future> fetch() = 0; +}; + +/// @brief Configurable token source: @ref fetch accepts @ref TokenRequestOptions. +class LIVEKIT_API TokenSourceConfigurable { +public: + virtual ~TokenSourceConfigurable(); + + /// Fetch connection credentials. + /// + /// @param options Connection parameters encoded into the token request. + /// @param force_refresh When @c true, bypass any cached credentials. + /// @return Future resolving to connection details or an error. + virtual std::future> fetch(const TokenRequestOptions& options = {}, + bool force_refresh = false) = 0; +}; + +/// @brief Fixed token source backed by static connection details or an async provider. +class LIVEKIT_API LiteralTokenSource final : public TokenSourceFixed { +public: + /// @brief Create a token source from static @ref ConnectionDetails. + static std::unique_ptr fromDetails(ConnectionDetails details); + + /// @brief Create a token source from an async provider (fixed credentials per call). + static std::unique_ptr fromProvider( + std::function>()> provider); + + std::future> fetch() override; + +private: + explicit LiteralTokenSource(ConnectionDetails details); + explicit LiteralTokenSource(std::function>()> provider); + + ConnectionDetails details_; + std::function>()> provider_; +}; + +/// @brief Configurable token source backed by custom application logic. +class LIVEKIT_API CustomTokenSource final : public TokenSourceConfigurable { +public: + /// @brief Create a token source that delegates fetching to @p provider. + static std::unique_ptr fromCallback( + std::function>(const TokenRequestOptions&)> provider); + + std::future> fetch(const TokenRequestOptions& options, + bool force_refresh = false) override; + +private: + explicit CustomTokenSource( + std::function>(const TokenRequestOptions&)> provider); + + std::function>(const TokenRequestOptions&)> provider_; +}; + +/// @brief Configurable token source that POSTs to a token-server endpoint. +/// +/// @see https://docs.livekit.io/frontends/build/authentication/endpoint/ +class LIVEKIT_API EndpointTokenSource final : public TokenSourceConfigurable { +public: + /// @brief Create a token source that fetches credentials from @p endpoint_url. + static std::unique_ptr fromUrl(std::string endpoint_url, TokenEndpointOptions options = {}); + + std::future> fetch(const TokenRequestOptions& options, + bool force_refresh = false) override; + +private: + EndpointTokenSource(std::string endpoint_url, TokenEndpointOptions options); + + Result fetchSync(const TokenRequestOptions& options) const; + + std::string endpoint_url_; + TokenEndpointOptions options_; +}; + +/// @brief Configurable token source for LiveKit Cloud sandbox (dev only). +/// +/// @see https://docs.livekit.io/frontends/build/authentication/sandbox-token-server/ +class LIVEKIT_API SandboxTokenSource final : public TokenSourceConfigurable { +public: + /// @brief Create a token source backed by the LiveKit Cloud sandbox token server. + static std::unique_ptr fromSandboxId(std::string sandbox_id, TokenEndpointOptions options = {}); + + std::future> fetch(const TokenRequestOptions& options, + bool force_refresh = false) override; + +private: + SandboxTokenSource(std::string sandbox_id, TokenEndpointOptions options); + + std::unique_ptr endpoint_; +}; + +/// @brief Configurable token source that caches JWT-aware credentials from an inner source. +class LIVEKIT_API CachingTokenSource final : public TokenSourceConfigurable { +public: + /// @brief Wrap @p inner with JWT-aware caching. + static std::unique_ptr wrap(std::unique_ptr inner); + + std::future> fetch(const TokenRequestOptions& options, + bool force_refresh = false) override; + +private: + explicit CachingTokenSource(std::unique_ptr inner); + + std::unique_ptr inner_; + mutable std::mutex mutex_; + std::optional cached_options_; + std::optional cached_details_; +}; + +} // namespace livekit diff --git a/src/room.cpp b/src/room.cpp index eb1e934a..7e428ac8 100644 --- a/src/room.cpp +++ b/src/room.cpp @@ -91,15 +91,33 @@ void Room::setDelegate(RoomDelegate* delegate) { delegate_ = delegate; } -bool Room::connect(const std::string& url, const TokenSource& token_source, const RoomOptions& options) { - if (!token_source) { - LK_LOG_ERROR("Room::connect failed: token source is empty"); +bool Room::connect(TokenSourceFixed& token_source, const RoomOptions& options) { + Result details = + Result::failure(TokenSourceError{"token source not invoked"}); + try { + details = token_source.fetch().get(); + } catch (const std::exception& e) { + LK_LOG_ERROR("Room::connect failed: token source threw: {}", e.what()); + return false; + } catch (...) { + LK_LOG_ERROR("Room::connect failed: token source threw unknown exception"); + return false; + } + + if (!details) { + LK_LOG_ERROR("Room::connect failed: token source error: {}", details.error().message); return false; } - std::string token; + return connect(details.value().server_url, details.value().participant_token, options); +} + +bool Room::connect(TokenSourceConfigurable& token_source, const TokenRequestOptions& request_options, + const RoomOptions& options) { + Result details = + Result::failure(TokenSourceError{"token source not invoked"}); try { - token = token_source().get(); + details = token_source.fetch(request_options, false).get(); } catch (const std::exception& e) { LK_LOG_ERROR("Room::connect failed: token source threw: {}", e.what()); return false; @@ -108,12 +126,12 @@ bool Room::connect(const std::string& url, const TokenSource& token_source, cons return false; } - if (token.empty()) { - LK_LOG_ERROR("Room::connect failed: token source returned empty token"); + if (!details) { + LK_LOG_ERROR("Room::connect failed: token source error: {}", details.error().message); return false; } - return connect(url, token, options); + return connect(details.value().server_url, details.value().participant_token, options); } bool Room::connect(const std::string& url, const std::string& token, const RoomOptions& options) { @@ -204,6 +222,7 @@ bool Room::connect(const std::string& url, const std::string& token, const RoomO local_participant_ = std::move(new_local_participant); remote_participants_ = std::move(new_remote_participants); e2ee_manager_ = std::move(new_e2ee_manager); + participant_token_ = token; connection_state_ = ConnectionState::Connected; } @@ -223,6 +242,7 @@ bool Room::connect(const std::string& url, const std::string& token, const RoomO remote_participants_.clear(); room_handle_.reset(); e2ee_manager_.reset(); + participant_token_.clear(); text_stream_readers_.clear(); byte_stream_readers_.clear(); } @@ -268,6 +288,7 @@ bool Room::disconnect(DisconnectReason reason) { listener_to_remove = listener_id_; listener_id_ = 0; room_handle_.reset(); + participant_token_.clear(); // Flip state immediately so the in-flight Disconnected room-event we'll // get back doesn't double-fire onDisconnected. Mirrors Python's // Room.disconnect() @@ -350,6 +371,11 @@ ConnectionState Room::connectionState() const { return connection_state_; } +std::string Room::participantToken() const { + const std::scoped_lock g(lock_); + return participant_token_; +} + std::future Room::getStats() const { std::shared_ptr handle; { @@ -1229,6 +1255,10 @@ void Room::onEvent(const FfiEvent& event) { } case proto::RoomEvent::kTokenRefreshed: { const TokenRefreshedEvent ev = fromProto(re.token_refreshed()); + { + const std::scoped_lock guard(lock_); + participant_token_ = ev.token; + } if (delegate_snapshot) { delegate_snapshot->onTokenRefreshed(*this, ev); } @@ -1257,6 +1287,7 @@ void Room::onEvent(const FfiEvent& event) { // Reset connection state connection_state_ = ConnectionState::Disconnected; + participant_token_.clear(); // Move state out for cleanup outside lock old_local_participant = std::move(local_participant_); diff --git a/src/tests/integration/test_room.cpp b/src/tests/integration/test_room.cpp index f9a2ff51..696518e3 100644 --- a/src/tests/integration/test_room.cpp +++ b/src/tests/integration/test_room.cpp @@ -73,22 +73,47 @@ TEST_F(RoomTest, ConnectWithTokenSource) { Room room; RoomOptions options; - int fetch_count = 0; - const TokenSource token_source = [this, &fetch_count]() -> std::future { - ++fetch_count; - std::promise promise; - promise.set_value(token_); - return promise.get_future(); - }; - - const bool connected = room.connect(server_url_, token_source, options); - EXPECT_TRUE(connected) << "Should connect to server via token source"; - EXPECT_EQ(fetch_count, 1) << "Token source should be invoked exactly once"; + ConnectionDetails details; + details.server_url = server_url_; + details.participant_token = token_; + + auto token_source = LiteralTokenSource::fromDetails(std::move(details)); + const bool connected = room.connect(*token_source, options); + EXPECT_TRUE(connected) << "Should connect to server via literal token source"; if (connected) { EXPECT_FALSE(room.localParticipant().expired()) << "Local participant should exist after connect"; EXPECT_EQ(room.connectionState(), ConnectionState::Connected); + EXPECT_EQ(room.participantToken(), token_); + } +} + +TEST_F(RoomTest, ConnectWithCustomTokenSource) { + if (!server_available_) { + GTEST_SKIP() << "LIVEKIT_URL and LIVEKIT_TOKEN_A not set"; } + + Room room; + RoomOptions options; + + auto token_source = CustomTokenSource::fromCallback( + [this](const TokenRequestOptions& options) -> std::future> { + std::promise> promise; + ConnectionDetails details; + details.server_url = server_url_; + details.participant_token = token_; + if (options.room_name.has_value()) { + details.room_name = options.room_name; + } + promise.set_value(Result::success(details)); + return promise.get_future(); + }); + + TokenRequestOptions request; + request.room_name = "integration-room"; + + const bool connected = room.connect(*token_source, request, options); + EXPECT_TRUE(connected) << "Should connect to server via custom token source"; } TEST_F(RoomTest, ConnectWithInvalidToken) { diff --git a/src/tests/unit/test_room.cpp b/src/tests/unit/test_room.cpp index 15b3d1e1..e5b0cce8 100644 --- a/src/tests/unit/test_room.cpp +++ b/src/tests/unit/test_room.cpp @@ -46,54 +46,79 @@ TEST_F(RoomTest, ConnectWithoutInitialize) { EXPECT_TRUE(room.remoteParticipants().empty()) << "Remote participants should be empty after failed connect"; } -TEST_F(RoomTest, ConnectWithEmptyTokenSourceFails) { +TEST_F(RoomTest, ConnectWithLiteralTokenSourceEmptyCredentialsFails) { Room room; - const bool result = room.connect("wss://localhost:7880", livekit::TokenSource{}, livekit::RoomOptions()); - EXPECT_FALSE(result) << "Connecting with an empty token source should return false"; + ConnectionDetails details; + details.server_url = "wss://localhost:7880"; + details.participant_token = ""; + + auto source = LiteralTokenSource::fromDetails(std::move(details)); + const bool result = room.connect(*source, RoomOptions()); + EXPECT_FALSE(result) << "Connecting with empty credentials should return false"; } -TEST_F(RoomTest, ConnectWithTokenSourceReturningEmptyFails) { - Room room; +TEST_F(RoomTest, ConnectWithLiteralTokenSourceWithoutInitialize) { + livekit::shutdown(); - livekit::TokenSource source = []() -> std::future { - std::promise promise; - promise.set_value(""); - return promise.get_future(); - }; + Room room; + ConnectionDetails details; + details.server_url = "wss://localhost:7880"; + details.participant_token = "jwt-token"; - const bool result = room.connect("wss://localhost:7880", source, livekit::RoomOptions()); - EXPECT_FALSE(result) << "Connecting with an empty token should return false"; + auto source = LiteralTokenSource::fromDetails(std::move(details)); + const bool result = room.connect(*source, RoomOptions()); + EXPECT_FALSE(result) << "Connecting without initializing should return false"; } -TEST_F(RoomTest, ConnectWithTokenSourceThrowingFails) { +TEST_F(RoomTest, ConnectWithCustomTokenSourceThrowingFails) { Room room; - livekit::TokenSource source = []() -> std::future { - std::promise promise; - promise.set_exception(std::make_exception_ptr(std::runtime_error("token fetch failed"))); - return promise.get_future(); - }; + auto source = CustomTokenSource::fromCallback( + [](const TokenRequestOptions&) -> std::future> { + std::promise> promise; + promise.set_exception(std::make_exception_ptr(std::runtime_error("token fetch failed"))); + return promise.get_future(); + }); - const bool result = room.connect("wss://localhost:7880", source, livekit::RoomOptions()); + const bool result = room.connect(*source, TokenRequestOptions{}, RoomOptions()); EXPECT_FALSE(result) << "Connecting when token source throws should return false"; } -TEST_F(RoomTest, ConnectWithTokenSourceInvokesCallbackBeforeConnectFailure) { +TEST_F(RoomTest, ConnectWithCustomTokenSourceErrorFails) { + Room room; + + auto source = CustomTokenSource::fromCallback( + [](const TokenRequestOptions&) -> std::future> { + std::promise> promise; + promise.set_value( + Result::failure(TokenSourceError{"backend unavailable"})); + return promise.get_future(); + }); + + const bool result = room.connect(*source, TokenRequestOptions{}, RoomOptions()); + EXPECT_FALSE(result) << "Connecting when token source returns error should return false"; +} + +TEST_F(RoomTest, ConnectWithLiteralTokenSourceInvokesFetchBeforeConnectFailure) { livekit::shutdown(); Room room; - int call_count = 0; - livekit::TokenSource source = [&call_count]() -> std::future { - ++call_count; - std::promise promise; - promise.set_value("fetched-token"); - return promise.get_future(); - }; - - const bool result = room.connect("wss://localhost:7880", source, livekit::RoomOptions()); + int fetch_count = 0; + auto source = + LiteralTokenSource::fromProvider([&fetch_count]() -> std::future> { + ++fetch_count; + ConnectionDetails details; + details.server_url = "wss://localhost:7880"; + details.participant_token = "fetched-token"; + std::promise> promise; + promise.set_value(Result::success(details)); + return promise.get_future(); + }); + + const bool result = room.connect(*source, RoomOptions()); EXPECT_FALSE(result) << "Connecting without initializing should return false"; - EXPECT_EQ(call_count, 1) << "Token source should be invoked once before connect fails"; + EXPECT_EQ(fetch_count, 1) << "Token source should be invoked once before connect fails"; } TEST(RoomOptionsProtoTest, TokenRefreshedFromProto) { diff --git a/src/tests/unit/test_token_source.cpp b/src/tests/unit/test_token_source.cpp new file mode 100644 index 00000000..e978013a --- /dev/null +++ b/src/tests/unit/test_token_source.cpp @@ -0,0 +1,151 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed 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. + */ + +#include +#include + +#include +#include +#include + +#include "token_source_internal.h" + +namespace livekit::test { + +TEST(TokenSourceJsonTest, BuildRequestJsonIncludesFields) { + TokenRequestOptions options; + options.room_name = "my-room"; + options.participant_identity = "user-1"; + options.participant_attributes["role"] = "host"; + options.agent_name = "assistant"; + + const std::string json = buildTokenSourceRequestJson(options); + EXPECT_NE(json.find("\"room_name\":\"my-room\""), std::string::npos); + EXPECT_NE(json.find("\"participant_identity\":\"user-1\""), std::string::npos); + EXPECT_NE(json.find("\"role\":\"host\""), std::string::npos); + EXPECT_NE(json.find("\"agent_name\":\"assistant\""), std::string::npos); +} + +TEST(TokenSourceJsonTest, ParseResponseSnakeCase) { + const std::string json = + R"({"server_url":"wss://example.livekit.io","participant_token":"jwt-token","room_name":"room-a"})"; + + const auto result = parseTokenSourceResponseJson(json); + ASSERT_TRUE(result); + EXPECT_EQ(result.value().server_url, "wss://example.livekit.io"); + EXPECT_EQ(result.value().participant_token, "jwt-token"); + ASSERT_TRUE(result.value().room_name.has_value()); + EXPECT_EQ(*result.value().room_name, "room-a"); +} + +TEST(TokenSourceJsonTest, ParseResponseCamelCase) { + const std::string json = + R"({"serverUrl":"wss://example.livekit.io","participantToken":"jwt-token","participantName":"Alice"})"; + + const auto result = parseTokenSourceResponseJson(json); + ASSERT_TRUE(result); + EXPECT_EQ(result.value().server_url, "wss://example.livekit.io"); + EXPECT_EQ(result.value().participant_token, "jwt-token"); + ASSERT_TRUE(result.value().participant_name.has_value()); + EXPECT_EQ(*result.value().participant_name, "Alice"); +} + +TEST(TokenSourceJwtTest, ValidAndExpiredTokens) { + const std::string valid_token = "eyJhbGciOiJub25lIn0.eyJleHAiOjk5OTk5OTk5OTk5fQ."; + const std::string expired_token = "eyJhbGciOiJub25lIn0.eyJleHAiOjF9."; + + EXPECT_TRUE(isParticipantTokenValid(valid_token)); + EXPECT_FALSE(isParticipantTokenValid(expired_token)); +} + +TEST(TokenSourceFactoryTest, LiteralTokenSourceReturnsDetails) { + ConnectionDetails details; + details.server_url = "wss://example.livekit.io"; + details.participant_token = "jwt-token"; + + auto source = LiteralTokenSource::fromDetails(details); + const auto result = source->fetch().get(); + ASSERT_TRUE(result); + EXPECT_EQ(result.value().server_url, details.server_url); + EXPECT_EQ(result.value().participant_token, details.participant_token); +} + +TEST(TokenSourceFactoryTest, CustomTokenSourceReceivesOptions) { + std::optional captured_room; + auto source = CustomTokenSource::fromCallback( + [&captured_room](const TokenRequestOptions& options) -> std::future> { + captured_room = options.room_name; + ConnectionDetails details; + details.server_url = "wss://example.livekit.io"; + details.participant_token = "jwt-token"; + std::promise> promise; + promise.set_value(Result::success(details)); + return promise.get_future(); + }); + + TokenRequestOptions request; + request.room_name = "requested-room"; + const auto result = source->fetch(request).get(); + ASSERT_TRUE(result); + ASSERT_TRUE(captured_room.has_value()); + EXPECT_EQ(*captured_room, "requested-room"); +} + +TEST(TokenSourceFactoryTest, CachingTokenSourceReusesValidToken) { + std::atomic fetch_count{0}; + auto inner = CustomTokenSource::fromCallback( + [&fetch_count](const TokenRequestOptions&) -> std::future> { + ++fetch_count; + ConnectionDetails details; + details.server_url = "wss://example.livekit.io"; + details.participant_token = "eyJhbGciOiJub25lIn0.eyJleHAiOjk5OTk5OTk5OTk5fQ."; + std::promise> promise; + promise.set_value(Result::success(details)); + return promise.get_future(); + }); + + auto cached = CachingTokenSource::wrap(std::move(inner)); + TokenRequestOptions request; + request.room_name = "room"; + + const auto first = cached->fetch(request).get(); + const auto second = cached->fetch(request).get(); + ASSERT_TRUE(first); + ASSERT_TRUE(second); + EXPECT_EQ(fetch_count.load(), 1); +} + +TEST(TokenSourceFactoryTest, CachingTokenSourceRefetchesWhenForced) { + std::atomic fetch_count{0}; + auto inner = CustomTokenSource::fromCallback( + [&fetch_count](const TokenRequestOptions&) -> std::future> { + ++fetch_count; + ConnectionDetails details; + details.server_url = "wss://example.livekit.io"; + details.participant_token = "eyJhbGciOiJub25lIn0.eyJleHAiOjk5OTk5OTk5OTk5fQ."; + std::promise> promise; + promise.set_value(Result::success(details)); + return promise.get_future(); + }); + + auto cached = CachingTokenSource::wrap(std::move(inner)); + TokenRequestOptions request; + + (void)cached->fetch(request).get(); + (void)cached->fetch(request, true).get(); + EXPECT_EQ(fetch_count.load(), 2); +} + +} // namespace livekit::test diff --git a/src/token_source.cpp b/src/token_source.cpp new file mode 100644 index 00000000..3cd85c44 --- /dev/null +++ b/src/token_source.cpp @@ -0,0 +1,153 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed 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 License governing permissions and limitations. + */ + +#include "livekit/token_source.h" + +#include +#include + +#include "token_source_internal.h" + +namespace livekit { +namespace { + +bool tokenRequestOptionsEqual(const TokenRequestOptions& a, const TokenRequestOptions& b) { + return a.room_name == b.room_name && a.participant_name == b.participant_name && + a.participant_identity == b.participant_identity && a.participant_metadata == b.participant_metadata && + a.participant_attributes == b.participant_attributes && a.agent_name == b.agent_name && + a.agent_metadata == b.agent_metadata && a.agent_deployment == b.agent_deployment; +} + +} // namespace + +TokenSourceFixed::~TokenSourceFixed() = default; + +TokenSourceConfigurable::~TokenSourceConfigurable() = default; + +std::unique_ptr LiteralTokenSource::fromDetails(ConnectionDetails details) { + return std::unique_ptr(new LiteralTokenSource(std::move(details))); +} + +std::unique_ptr LiteralTokenSource::fromProvider( + std::function>()> provider) { + return std::unique_ptr(new LiteralTokenSource(std::move(provider))); +} + +LiteralTokenSource::LiteralTokenSource(ConnectionDetails details) : details_(std::move(details)) {} + +LiteralTokenSource::LiteralTokenSource( + std::function>()> provider) + : provider_(std::move(provider)) {} + +std::future> LiteralTokenSource::fetch() { + if (provider_) { + return provider_(); + } + + return std::async(std::launch::deferred, [details = details_]() { + if (details.server_url.empty() || details.participant_token.empty()) { + return Result::failure( + TokenSourceError{"literal token source returned empty server_url or participant_token"}); + } + return Result::success(details); + }); +} + +std::unique_ptr CustomTokenSource::fromCallback( + std::function>(const TokenRequestOptions&)> provider) { + return std::unique_ptr(new CustomTokenSource(std::move(provider))); +} + +CustomTokenSource::CustomTokenSource( + std::function>(const TokenRequestOptions&)> provider) + : provider_(std::move(provider)) {} + +std::future> CustomTokenSource::fetch(const TokenRequestOptions& options, + bool /*force_refresh*/) { + return provider_(options); +} + +std::unique_ptr EndpointTokenSource::fromUrl(std::string endpoint_url, + TokenEndpointOptions options) { + return std::unique_ptr(new EndpointTokenSource(std::move(endpoint_url), std::move(options))); +} + +EndpointTokenSource::EndpointTokenSource(std::string endpoint_url, TokenEndpointOptions options) + : endpoint_url_(std::move(endpoint_url)), options_(std::move(options)) {} + +std::future> EndpointTokenSource::fetch(const TokenRequestOptions& options, + bool /*force_refresh*/) { + return std::async(std::launch::async, [this, options]() { return fetchSync(options); }); +} + +Result EndpointTokenSource::fetchSync(const TokenRequestOptions& options) const { + const std::string request_json = buildTokenSourceRequestJson(options); + auto headers = options_.headers; + auto http_result = tokenSourceHttpPost(endpoint_url_, headers, request_json, options_.timeout); + if (!http_result) { + return Result::failure( + TokenSourceError{"token server request failed: " + http_result.error()}); + } + return parseTokenSourceResponseJson(http_result.value()); +} + +std::unique_ptr SandboxTokenSource::fromSandboxId(std::string sandbox_id, + TokenEndpointOptions options) { + return std::unique_ptr(new SandboxTokenSource(std::move(sandbox_id), std::move(options))); +} + +SandboxTokenSource::SandboxTokenSource(std::string sandbox_id, TokenEndpointOptions options) { + options.headers["X-Sandbox-ID"] = sandbox_id; + endpoint_ = EndpointTokenSource::fromUrl("https://cloud-api.livekit.io/api/v2/sandbox/connection-details", + std::move(options)); +} + +std::future> SandboxTokenSource::fetch(const TokenRequestOptions& options, + bool force_refresh) { + return endpoint_->fetch(options, force_refresh); +} + +std::unique_ptr CachingTokenSource::wrap(std::unique_ptr inner) { + return std::unique_ptr(new CachingTokenSource(std::move(inner))); +} + +CachingTokenSource::CachingTokenSource(std::unique_ptr inner) : inner_(std::move(inner)) {} + +std::future> CachingTokenSource::fetch(const TokenRequestOptions& options, + bool force_refresh) { + { + const std::scoped_lock lock(mutex_); + if (!force_refresh && cached_details_.has_value() && cached_options_.has_value() && + tokenRequestOptionsEqual(*cached_options_, options) && + isParticipantTokenValid(cached_details_->participant_token)) { + return std::async(std::launch::deferred, [details = *cached_details_]() { + return Result::success(details); + }); + } + } + + auto future = inner_->fetch(options, force_refresh); + return std::async(std::launch::async, [this, future = std::move(future), options]() mutable { + auto result = future.get(); + if (result) { + const std::scoped_lock lock(mutex_); + cached_options_ = options; + cached_details_ = result.value(); + } + return result; + }); +} + +} // namespace livekit diff --git a/src/token_source_http.cpp b/src/token_source_http.cpp new file mode 100644 index 00000000..260b5e30 --- /dev/null +++ b/src/token_source_http.cpp @@ -0,0 +1,217 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed 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 License governing permissions and limitations. + */ + +#include +#include + +#include "token_source_internal.h" + +#if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#else +#include +#endif + +namespace livekit { +namespace { + +#if !defined(_WIN32) +size_t curlWriteCallback(char* contents, size_t size, size_t nmemb, void* user_data) { + const size_t total_size = size * nmemb; + auto* response = static_cast(user_data); + response->append(contents, total_size); + return total_size; +} +#endif + +#if defined(_WIN32) +std::wstring toWide(const std::string& value) { + if (value.empty()) { + return L""; + } + const int length = MultiByteToWideChar(CP_UTF8, 0, value.c_str(), static_cast(value.size()), nullptr, 0); + if (length <= 0) { + return L""; + } + std::wstring wide(static_cast(length), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, value.c_str(), static_cast(value.size()), wide.data(), length); + return wide; +} + +Result winHttpPost(const std::string& url, const std::map& headers, + const std::string& json_body, std::chrono::milliseconds timeout) { + URL_COMPONENTS components{}; + components.dwStructSize = sizeof(components); + components.dwSchemeLength = static_cast(-1); + components.dwHostNameLength = static_cast(-1); + components.dwUrlPathLength = static_cast(-1); + components.dwExtraInfoLength = static_cast(-1); + + const std::wstring wide_url = toWide(url); + if (!WinHttpCrackUrl(wide_url.c_str(), 0, 0, &components)) { + return Result::failure("failed to parse token server URL"); + } + + const std::wstring host(components.lpszHostName, components.dwHostNameLength); + const std::wstring path(components.lpszUrlPath, components.dwUrlPathLength); + + HINTERNET session = WinHttpOpen(L"LiveKit-CPP/1.0", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, WINHTTP_NO_PROXY_NAME, + WINHTTP_NO_PROXY_BYPASS, 0); + if (session == nullptr) { + return Result::failure("WinHttpOpen failed"); + } + + const int timeout_ms = static_cast(timeout.count()); + WinHttpSetTimeouts(session, timeout_ms, timeout_ms, timeout_ms, timeout_ms); + + HINTERNET connection = WinHttpConnect(session, host.c_str(), components.nPort, 0); + if (connection == nullptr) { + WinHttpCloseHandle(session); + return Result::failure("WinHttpConnect failed"); + } + + const DWORD flags = (components.nScheme == INTERNET_SCHEME_HTTPS) ? WINHTTP_FLAG_SECURE : 0; + HINTERNET request = WinHttpOpenRequest(connection, L"POST", path.c_str(), nullptr, WINHTTP_NO_REFERER, + WINHTTP_DEFAULT_ACCEPT_TYPES, flags); + if (request == nullptr) { + WinHttpCloseHandle(connection); + WinHttpCloseHandle(session); + return Result::failure("WinHttpOpenRequest failed"); + } + + std::wstring header_block = L"Content-Type: application/json\r\n"; + for (const auto& [key, value] : headers) { + header_block += toWide(key); + header_block += L": "; + header_block += toWide(value); + header_block += L"\r\n"; + } + + const BOOL send_ok = + WinHttpSendRequest(request, header_block.c_str(), static_cast(-1L), const_cast(json_body.data()), + static_cast(json_body.size()), static_cast(json_body.size()), 0); + if (!send_ok) { + WinHttpCloseHandle(request); + WinHttpCloseHandle(connection); + WinHttpCloseHandle(session); + return Result::failure("WinHttpSendRequest failed"); + } + + if (!WinHttpReceiveResponse(request, nullptr)) { + WinHttpCloseHandle(request); + WinHttpCloseHandle(connection); + WinHttpCloseHandle(session); + return Result::failure("WinHttpReceiveResponse failed"); + } + + DWORD status_code = 0; + DWORD status_size = sizeof(status_code); + WinHttpQueryHeaders(request, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, WINHTTP_HEADER_NAME_BY_INDEX, + &status_code, &status_size, WINHTTP_NO_HEADER_INDEX); + + std::string response_body; + DWORD available = 0; + do { + if (!WinHttpQueryDataAvailable(request, &available)) { + break; + } + if (available == 0) { + break; + } + + std::string chunk(available, '\0'); + DWORD read = 0; + if (!WinHttpReadData(request, chunk.data(), available, &read)) { + break; + } + chunk.resize(read); + response_body += chunk; + } while (available > 0); + + WinHttpCloseHandle(request); + WinHttpCloseHandle(connection); + WinHttpCloseHandle(session); + + if (status_code < 200 || status_code >= 300) { + std::ostringstream message; + message << "token server HTTP " << status_code << ": " << response_body; + return Result::failure(message.str()); + } + + return Result::success(std::move(response_body)); +} +#endif + +} // namespace + +Result tokenSourceHttpPost(const std::string& url, + const std::map& headers, + const std::string& json_body, std::chrono::milliseconds timeout) { +#if defined(_WIN32) + return winHttpPost(url, headers, json_body, timeout); +#else + CURL* curl = curl_easy_init(); + if (curl == nullptr) { + return Result::failure("curl_easy_init failed"); + } + + std::string response_body; + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_POST, 1L); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json_body.c_str()); + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, static_cast(json_body.size())); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curlWriteCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response_body); + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, static_cast(timeout.count())); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "LiveKit-CPP/1.0"); + + struct curl_slist* curl_headers = nullptr; + curl_headers = curl_slist_append(curl_headers, "Content-Type: application/json"); + for (const auto& [key, value] : headers) { + const std::string header = key + ": " + value; + curl_headers = curl_slist_append(curl_headers, header.c_str()); + } + if (curl_headers != nullptr) { + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, curl_headers); + } + + const CURLcode perform_result = curl_easy_perform(curl); + long status_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status_code); + + if (curl_headers != nullptr) { + curl_slist_free_all(curl_headers); + } + curl_easy_cleanup(curl); + + if (perform_result != CURLE_OK) { + return Result::failure(curl_easy_strerror(perform_result)); + } + + if (status_code < 200 || status_code >= 300) { + std::ostringstream message; + message << "token server HTTP " << status_code << ": " << response_body; + return Result::failure(message.str()); + } + + return Result::success(std::move(response_body)); +#endif +} + +} // namespace livekit diff --git a/src/token_source_internal.h b/src/token_source_internal.h new file mode 100644 index 00000000..f91809f7 --- /dev/null +++ b/src/token_source_internal.h @@ -0,0 +1,42 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed 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 License governing permissions and limitations. + */ + +#pragma once + +#include +#include +#include + +#include "livekit/result.h" +#include "livekit/token_source.h" +#include "livekit/visibility.h" + +namespace livekit { + +/// @brief Perform an HTTPS/HTTP POST with a JSON body (internal). +LIVEKIT_INTERNAL_API Result tokenSourceHttpPost( + const std::string& url, const std::map& headers, const std::string& json_body, + std::chrono::milliseconds timeout); + +/// @brief Build the standard LiveKit token-server JSON request body. +LIVEKIT_INTERNAL_API std::string buildTokenSourceRequestJson(const TokenRequestOptions& options); + +/// @brief Parse a token-server JSON response into @ref ConnectionDetails. +LIVEKIT_INTERNAL_API Result parseTokenSourceResponseJson(const std::string& json); + +/// @brief Return @c true when the JWT is within its validity window (1-minute skew buffer). +LIVEKIT_INTERNAL_API bool isParticipantTokenValid(const std::string& participant_token); + +} // namespace livekit diff --git a/src/token_source_json.cpp b/src/token_source_json.cpp new file mode 100644 index 00000000..ccf14ae0 --- /dev/null +++ b/src/token_source_json.cpp @@ -0,0 +1,199 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed 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 License governing permissions and limitations. + */ + +#include +#include +#include + +#include "token_source_internal.h" + +namespace livekit { +namespace { + +std::string jsonEscape(const std::string& value) { + std::string escaped; + escaped.reserve(value.size() + 8); + for (const char ch : value) { + switch (ch) { + case '\\': + escaped += "\\\\"; + break; + case '"': + escaped += "\\\""; + break; + case '\n': + escaped += "\\n"; + break; + case '\r': + escaped += "\\r"; + break; + case '\t': + escaped += "\\t"; + break; + default: + escaped += ch; + break; + } + } + return escaped; +} + +void appendOptionalStringField(std::ostringstream& out, const char* key, const std::optional& value) { + if (!value.has_value() || value->empty()) { + return; + } + if (out.tellp() > 1) { + out << ','; + } + out << '"' << key << "\":\"" << jsonEscape(*value) << '"'; +} + +std::optional extractJsonStringField(const std::string& json, const char* key) { + const std::string snake_key = std::string("\"") + key + "\":\""; + const std::string camel_key = std::string("\"") + key + "\":\""; + + std::size_t pos = json.find(snake_key); + std::size_t key_len = snake_key.size(); + if (pos == std::string::npos) { + pos = json.find(camel_key); + key_len = camel_key.size(); + } + if (pos == std::string::npos) { + return std::nullopt; + } + + pos += key_len; + std::string value; + for (; pos < json.size(); ++pos) { + const char ch = json[pos]; + if (ch == '"') { + break; + } + if (ch == '\\' && pos + 1 < json.size()) { + ++pos; + value += json[pos]; + continue; + } + value += ch; + } + return value; +} + +} // namespace + +std::string buildTokenSourceRequestJson(const TokenRequestOptions& options) { + std::ostringstream out; + out << '{'; + + appendOptionalStringField(out, "room_name", options.room_name); + appendOptionalStringField(out, "participant_name", options.participant_name); + appendOptionalStringField(out, "participant_identity", options.participant_identity); + appendOptionalStringField(out, "participant_metadata", options.participant_metadata); + + if (!options.participant_attributes.empty()) { + if (out.tellp() > 1) { + out << ','; + } + out << "\"participant_attributes\":{"; + bool first = true; + for (const auto& [key, value] : options.participant_attributes) { + if (key.empty()) { + continue; + } + if (!first) { + out << ','; + } + first = false; + out << '"' << jsonEscape(key) << "\":\"" << jsonEscape(value) << '"'; + } + out << '}'; + } + + if (options.agent_name.has_value() || options.agent_metadata.has_value() || options.agent_deployment.has_value()) { + if (out.tellp() > 1) { + out << ','; + } + out << "\"room_config\":{\"agents\":[{"; + bool wrote_agent_field = false; + if (options.agent_name.has_value() && !options.agent_name->empty()) { + out << "\"agent_name\":\"" << jsonEscape(*options.agent_name) << '"'; + wrote_agent_field = true; + } + if (options.agent_metadata.has_value() && !options.agent_metadata->empty()) { + if (wrote_agent_field) { + out << ','; + } + out << "\"metadata\":\"" << jsonEscape(*options.agent_metadata) << '"'; + wrote_agent_field = true; + } + if (options.agent_deployment.has_value() && !options.agent_deployment->empty()) { + if (wrote_agent_field) { + out << ','; + } + out << "\"deployment\":\"" << jsonEscape(*options.agent_deployment) << '"'; + } + out << "}]}"; + } + + out << '}'; + return out.str(); +} + +Result parseTokenSourceResponseJson(const std::string& json) { + ConnectionDetails details; + + const auto server_url = extractJsonStringField(json, "server_url"); + if (!server_url.has_value()) { + const auto camel_url = extractJsonStringField(json, "serverUrl"); + if (!camel_url.has_value() || camel_url->empty()) { + return Result::failure( + TokenSourceError{"token server response missing server_url"}); + } + details.server_url = *camel_url; + } else { + details.server_url = *server_url; + } + + const auto participant_token = extractJsonStringField(json, "participant_token"); + if (!participant_token.has_value()) { + const auto camel_token = extractJsonStringField(json, "participantToken"); + if (!camel_token.has_value() || camel_token->empty()) { + return Result::failure( + TokenSourceError{"token server response missing participant_token"}); + } + details.participant_token = *camel_token; + } else { + details.participant_token = *participant_token; + } + + details.participant_name = extractJsonStringField(json, "participant_name"); + if (!details.participant_name.has_value()) { + details.participant_name = extractJsonStringField(json, "participantName"); + } + + details.room_name = extractJsonStringField(json, "room_name"); + if (!details.room_name.has_value()) { + details.room_name = extractJsonStringField(json, "roomName"); + } + + if (details.server_url.empty() || details.participant_token.empty()) { + return Result::failure( + TokenSourceError{"token server response contained empty server_url or participant_token"}); + } + + return Result::success(std::move(details)); +} + +} // namespace livekit diff --git a/src/token_source_jwt.cpp b/src/token_source_jwt.cpp new file mode 100644 index 00000000..0ad89625 --- /dev/null +++ b/src/token_source_jwt.cpp @@ -0,0 +1,161 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed 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 License governing permissions and limitations. + */ + +#include +#include +#include +#include +#include +#include + +#include "token_source_internal.h" + +namespace livekit { +namespace { + +std::optional> base64UrlDecode(const std::string& input) { + std::string normalized; + normalized.reserve(input.size()); + for (char ch : input) { + if (ch == '-') { + normalized += '+'; + } else if (ch == '_') { + normalized += '/'; + } else { + normalized += ch; + } + } + + while (normalized.size() % 4 != 0) { + normalized += '='; + } + + static const int kDecodeTable[256] = { + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63, 52, 53, 54, 55, 56, 57, + 58, 59, 60, 61, -1, -1, -1, -1, -1, -1, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, + 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, -1, -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, + 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}; + + std::vector output; + output.reserve(normalized.size() * 3 / 4); + + std::uint32_t buffer = 0; + int bits = 0; + for (const unsigned char ch : normalized) { + if (ch == '=') { + break; + } + const int value = kDecodeTable[ch]; + if (value < 0) { + return std::nullopt; + } + buffer = (buffer << 6) | static_cast(value); + bits += 6; + if (bits >= 8) { + bits -= 8; + output.push_back(static_cast((buffer >> bits) & 0xFF)); + } + } + + return output; +} + +std::optional extractJsonInt64Field(const std::string& json, const char* key) { + const std::string needle = std::string("\"") + key + "\":"; + const std::size_t pos = json.find(needle); + if (pos == std::string::npos) { + return std::nullopt; + } + + std::size_t index = pos + needle.size(); + while (index < json.size() && std::isspace(static_cast(json[index])) != 0) { + ++index; + } + + bool negative = false; + if (index < json.size() && json[index] == '-') { + negative = true; + ++index; + } + + std::int64_t value = 0; + bool found_digit = false; + for (; index < json.size(); ++index) { + const char ch = json[index]; + if (!std::isdigit(static_cast(ch))) { + break; + } + found_digit = true; + value = value * 10 + (ch - '0'); + } + + if (!found_digit) { + return std::nullopt; + } + return negative ? -value : value; +} + +std::optional extractJwtPayloadJson(const std::string& token) { + const std::size_t first_dot = token.find('.'); + if (first_dot == std::string::npos) { + return std::nullopt; + } + const std::size_t second_dot = token.find('.', first_dot + 1); + if (second_dot == std::string::npos) { + return std::nullopt; + } + + const std::string payload_segment = token.substr(first_dot + 1, second_dot - first_dot - 1); + const auto decoded = base64UrlDecode(payload_segment); + if (!decoded.has_value() || decoded->empty()) { + return std::nullopt; + } + + return std::string(decoded->begin(), decoded->end()); +} + +} // namespace + +bool isParticipantTokenValid(const std::string& participant_token) { + const auto payload_json = extractJwtPayloadJson(participant_token); + if (!payload_json.has_value()) { + return true; + } + + const auto now_seconds = + std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); + + const auto nbf = extractJsonInt64Field(*payload_json, "nbf"); + if (nbf.has_value() && *nbf > now_seconds) { + return false; + } + + const auto exp = extractJsonInt64Field(*payload_json, "exp"); + if (exp.has_value()) { + constexpr std::int64_t kExpiryBufferSeconds = 60; + if (*exp <= now_seconds + kExpiryBufferSeconds) { + return false; + } + } + + return true; +} + +} // namespace livekit From eda9a9de1183a823d82257170ae7c3d8e1bcb952 Mon Sep 17 00:00:00 2001 From: Alan George Date: Thu, 18 Jun 2026 10:37:32 -0600 Subject: [PATCH 4/4] Cleanup for clang/CI build issues --- .github/workflows/tests.yml | 1 + docker/Dockerfile.base | 1 + include/livekit/token_source.h | 7 +++--- src/token_source.cpp | 41 +++++++++++++++++++++++++--------- src/token_source_http.cpp | 6 ++++- src/token_source_json.cpp | 10 ++++----- src/token_source_jwt.cpp | 2 +- 7 files changed, 47 insertions(+), 21 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 21922020..08d3d3f5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -310,6 +310,7 @@ jobs: libssl-dev \ libprotobuf-dev protobuf-compiler \ libabsl-dev \ + libcurl4-openssl-dev \ libwayland-dev libdecor-0-dev pip install --break-system-packages gcovr diff --git a/docker/Dockerfile.base b/docker/Dockerfile.base index 54559254..8e554a8c 100644 --- a/docker/Dockerfile.base +++ b/docker/Dockerfile.base @@ -30,6 +30,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libasound2-dev \ libabsl-dev \ libclang-dev \ + libcurl4-openssl-dev \ libdrm-dev \ libglib2.0-dev \ libprotobuf-dev \ diff --git a/include/livekit/token_source.h b/include/livekit/token_source.h index 4b8a9045..0658cee1 100644 --- a/include/livekit/token_source.h +++ b/include/livekit/token_source.h @@ -16,7 +16,6 @@ #pragma once #include -#include #include #include #include @@ -24,7 +23,6 @@ #include #include #include -#include #include "livekit/result.h" #include "livekit/visibility.h" @@ -163,13 +161,14 @@ class LIVEKIT_API EndpointTokenSource final : public TokenSourceConfigurable { class LIVEKIT_API SandboxTokenSource final : public TokenSourceConfigurable { public: /// @brief Create a token source backed by the LiveKit Cloud sandbox token server. - static std::unique_ptr fromSandboxId(std::string sandbox_id, TokenEndpointOptions options = {}); + static std::unique_ptr fromSandboxId(const std::string& sandbox_id, + TokenEndpointOptions options = {}); std::future> fetch(const TokenRequestOptions& options, bool force_refresh = false) override; private: - SandboxTokenSource(std::string sandbox_id, TokenEndpointOptions options); + SandboxTokenSource(const std::string& sandbox_id, TokenEndpointOptions options); std::unique_ptr endpoint_; }; diff --git a/src/token_source.cpp b/src/token_source.cpp index 3cd85c44..e6db24e3 100644 --- a/src/token_source.cpp +++ b/src/token_source.cpp @@ -15,6 +15,7 @@ #include "livekit/token_source.h" +#include #include #include @@ -89,7 +90,18 @@ EndpointTokenSource::EndpointTokenSource(std::string endpoint_url, TokenEndpoint std::future> EndpointTokenSource::fetch(const TokenRequestOptions& options, bool /*force_refresh*/) { - return std::async(std::launch::async, [this, options]() { return fetchSync(options); }); + // NOLINTNEXTLINE(bugprone-exception-escape): std::async may propagate allocation failures from captures. + return std::async(std::launch::async, [this, options]() { + try { + return fetchSync(options); + } catch (const std::exception& e) { + return Result::failure( + TokenSourceError{"token source endpoint fetch failed: " + std::string(e.what())}); + } catch (...) { + return Result::failure( + TokenSourceError{"token source endpoint fetch failed: unknown exception"}); + } + }); } Result EndpointTokenSource::fetchSync(const TokenRequestOptions& options) const { @@ -103,12 +115,12 @@ Result EndpointTokenSource::fetchSync(const return parseTokenSourceResponseJson(http_result.value()); } -std::unique_ptr SandboxTokenSource::fromSandboxId(std::string sandbox_id, +std::unique_ptr SandboxTokenSource::fromSandboxId(const std::string& sandbox_id, TokenEndpointOptions options) { - return std::unique_ptr(new SandboxTokenSource(std::move(sandbox_id), std::move(options))); + return std::unique_ptr(new SandboxTokenSource(sandbox_id, std::move(options))); } -SandboxTokenSource::SandboxTokenSource(std::string sandbox_id, TokenEndpointOptions options) { +SandboxTokenSource::SandboxTokenSource(const std::string& sandbox_id, TokenEndpointOptions options) { options.headers["X-Sandbox-ID"] = sandbox_id; endpoint_ = EndpointTokenSource::fromUrl("https://cloud-api.livekit.io/api/v2/sandbox/connection-details", std::move(options)); @@ -139,14 +151,23 @@ std::future> CachingTokenSource::fet } auto future = inner_->fetch(options, force_refresh); + // NOLINTNEXTLINE(bugprone-exception-escape): std::async may propagate allocation failures from captures. return std::async(std::launch::async, [this, future = std::move(future), options]() mutable { - auto result = future.get(); - if (result) { - const std::scoped_lock lock(mutex_); - cached_options_ = options; - cached_details_ = result.value(); + try { + auto result = future.get(); + if (result) { + const std::scoped_lock lock(mutex_); + cached_options_ = options; + cached_details_ = result.value(); + } + return result; + } catch (const std::exception& e) { + return Result::failure( + TokenSourceError{"token source cache refresh failed: " + std::string(e.what())}); + } catch (...) { + return Result::failure( + TokenSourceError{"token source cache refresh failed: unknown exception"}); } - return result; }); } diff --git a/src/token_source_http.cpp b/src/token_source_http.cpp index 260b5e30..10c7b688 100644 --- a/src/token_source_http.cpp +++ b/src/token_source_http.cpp @@ -184,7 +184,11 @@ Result tokenSourceHttpPost(const std::string& url, struct curl_slist* curl_headers = nullptr; curl_headers = curl_slist_append(curl_headers, "Content-Type: application/json"); for (const auto& [key, value] : headers) { - const std::string header = key + ": " + value; + std::string header; + header.reserve(key.size() + 2 + value.size()); + header.append(key); + header.append(": "); + header.append(value); curl_headers = curl_slist_append(curl_headers, header.c_str()); } if (curl_headers != nullptr) { diff --git a/src/token_source_json.cpp b/src/token_source_json.cpp index ccf14ae0..d460b18e 100644 --- a/src/token_source_json.cpp +++ b/src/token_source_json.cpp @@ -125,26 +125,26 @@ std::string buildTokenSourceRequestJson(const TokenRequestOptions& options) { if (out.tellp() > 1) { out << ','; } - out << "\"room_config\":{\"agents\":[{"; + out << R"("room_config":{"agents":[{)"; bool wrote_agent_field = false; if (options.agent_name.has_value() && !options.agent_name->empty()) { - out << "\"agent_name\":\"" << jsonEscape(*options.agent_name) << '"'; + out << R"("agent_name":")" << jsonEscape(*options.agent_name) << '"'; wrote_agent_field = true; } if (options.agent_metadata.has_value() && !options.agent_metadata->empty()) { if (wrote_agent_field) { out << ','; } - out << "\"metadata\":\"" << jsonEscape(*options.agent_metadata) << '"'; + out << R"("metadata":")" << jsonEscape(*options.agent_metadata) << '"'; wrote_agent_field = true; } if (options.agent_deployment.has_value() && !options.agent_deployment->empty()) { if (wrote_agent_field) { out << ','; } - out << "\"deployment\":\"" << jsonEscape(*options.agent_deployment) << '"'; + out << R"("deployment":")" << jsonEscape(*options.agent_deployment) << '"'; } - out << "}]}"; + out << R"(}]})"; } out << '}'; diff --git a/src/token_source_jwt.cpp b/src/token_source_jwt.cpp index 0ad89625..8205b257 100644 --- a/src/token_source_jwt.cpp +++ b/src/token_source_jwt.cpp @@ -28,7 +28,7 @@ namespace { std::optional> base64UrlDecode(const std::string& input) { std::string normalized; normalized.reserve(input.size()); - for (char ch : input) { + for (const char ch : input) { if (ch == '-') { normalized += '+'; } else if (ch == '_') {