diff --git a/.github/scripts/check_no_private_symbols.py b/.github/scripts/check_no_private_symbols.py index 4412a9af..d5224be0 100755 --- a/.github/scripts/check_no_private_symbols.py +++ b/.github/scripts/check_no_private_symbols.py @@ -17,7 +17,7 @@ Verify that liblivekit's exported ABI does not leak private dependency symbols. The LiveKit SDK statically links several private dependencies (spdlog, fmt, -google::protobuf, absl). When those symbols escape the dynamic symbol table +google::protobuf, absl, nlohmann/json). When those symbols escape the dynamic symbol table of liblivekit.{so,dylib,dll}, they collide at runtime with the same libraries loaded elsewhere in the host process (a common failure mode is ROS 2's rcl_logging_spdlog ABI-clashing with our vendored spdlog and crashing inside @@ -52,6 +52,7 @@ "fmt::v", "google::protobuf", "absl::", + "nlohmann::", ] MAX_REPORTED_LEAKS = 20 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..fb01d0d6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,6 +12,9 @@ permissions: env: CARGO_TERM_COLOR: always + # Port for livekit-examples/token-server-node in e2e jobs. Passed to the server + # process and read by TokenSourceEndpointConnectTest to build the /createToken URL. + TOKEN_SERVER_PORT: "3000" # Disable Cargo incremental artifacts. The Rust FFI is built once per # submodule SHA and cached whole (see the cargo target cache below), so # incremental dirs add nothing but bloat the cached target/ directory and @@ -114,6 +117,7 @@ jobs: libssl-dev \ libprotobuf-dev protobuf-compiler \ libabsl-dev \ + libcurl4-openssl-dev \ libwayland-dev libdecor-0-dev \ jq @@ -207,7 +211,6 @@ jobs: --gtest_brief=1 ` --gtest_output="xml:build-release\unit-test-results.xml" - # ---------- Start livekit-server for integration tests ---------- - name: Start livekit-server if: matrix.e2e-testing id: livekit_server @@ -228,6 +231,22 @@ jobs: fi lk --version + # Stand up token-server-node against the local dev livekit-server so + # EndpointTokenSource can be exercised end-to-end with a real, connectable + # JWT (see TokenSourceEndpointConnectTest). The token server ships its own + # reusable composite action (like livekit/dev-server-action); we pin it to a + # commit SHA on an unmerged branch of the public repo. TODO: repin to a + # released tag once the action + tsconfig fix are merged. + # TOKEN_SERVER_PORT is inherited from the workflow env block above. + - name: Start token-server-node (real token endpoint) + if: matrix.e2e-testing + id: token_server + uses: livekit-examples/token-server-node@9980bad8ce1d6d753dae7f33112e2c85d1ddba42 + with: + livekit-url: ws://localhost:7880 + api-key: devkey + api-secret: secret + - name: Run integration tests if: matrix.e2e-testing timeout-minutes: 5 @@ -245,6 +264,11 @@ jobs: shell: bash run: tail -n 500 "${{ steps.livekit_server.outputs.log-path }}" || true + - name: Dump token-server-node log on failure + if: failure() && matrix.e2e-testing && steps.token_server.outputs.log-path != '' + shell: bash + run: tail -n 200 "${{ steps.token_server.outputs.log-path }}" || true + # ---------- Upload results ---------- - name: Upload test results if: always() @@ -309,6 +333,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/AGENTS.md b/AGENTS.md index 9bf3afd7..2f21466d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ Be sure to update the directory layout in this file if the directory layout chan | `examples/` | In-tree example applications | | `client-sdk-rust/` | Git submodule holding the Rust core of the SDK| | `client-sdk-rust/livekit-ffi/protocol/*.proto` | FFI contract (protobuf definitions, read-only reference) | -| `cmake/` | Build helpers (`protobuf.cmake`, `spdlog.cmake`, `LiveKitConfig.cmake.in`) | +| `cmake/` | Build helpers (`protobuf.cmake`, `spdlog.cmake`, `nlohmann_json.cmake`, `LiveKitConfig.cmake.in`) | | `docker/` | Dockerfile for CI and SDK distribution images | | `scripts/` | Developer / CI helper scripts (e.g. `clang-tidy.sh`) | | `docs/` | Documentation root. `docs/` holds hand-written long-form Markdown intended to also read well on GitHub. | @@ -338,6 +338,7 @@ Adhere to clang-tidy checks configured in `.clang-tidy`. After C++ code changes, |------------|-------|-------| | protobuf | Private (built-in) | Vendored via FetchContent (Unix) or vcpkg (Windows) | | spdlog | **Private** | FetchContent or system package; must NOT leak into public API | +| nlohmann/json | **Private** | Header-only; vendored via FetchContent (Unix) or vcpkg (Windows); must NOT leak into public API | | client-sdk-rust | Build-time | Git submodule, built via cargo during CMake build | | Google Test | Test only | FetchContent in `src/tests/CMakeLists.txt` | @@ -356,7 +357,7 @@ Tests are under `src/tests/` using Google Test: cd build-debug && ctest ``` -Integration tests (`src/tests/integration/`) cover: room connections, callbacks, data tracks, RPC, logging, audio processing, and the subscription thread dispatcher. +Integration tests (`src/tests/integration/`) cover: room connections, callbacks, data tracks, RPC, logging, audio processing, and the subscription thread dispatcher. The token source HTTP/JSON wire contract (request serialization, response parsing, header passthrough, GET support, sandbox URL/header resolution) is covered by mocked unit tests in `src/tests/unit/test_token_source.cpp`, which inject a stub HTTP transport so no live server is needed. For a full end-to-end check, `TokenSourceEndpointConnectTest` connects a `Room` with a real JWT minted by the official `livekit-examples/token-server-node` server pointed at the local dev `livekit-server`. CI sets `TOKEN_SERVER_PORT` in `tests.yml`; the test and the token server both read that env var to agree on the `/createToken` URL (with optional `LIVEKIT_CREATE_TOKEN_URL` override). The server is started in the `e2e-testing` jobs via the token server's reusable GitHub Action, pinned by SHA in `tests.yml`. When adding new client facing functionality, add a new test case to the existing test suite. When adding new client facing functionality, add benchmarking to understand the limitations of the new functionality. @@ -403,6 +404,13 @@ all filtered stages; normal pull requests and pushes use the path filters. - `.github/workflows/docker-validate.yml` — Docker image validation workflow, outside PR-review aggregation. +The `tests.yml` e2e jobs consume two external, pinned composite actions: +`livekit/dev-server-action` (local `livekit-server`) and +`livekit-examples/token-server-node` (a real `/createToken` endpoint used by +`TokenSourceEndpointConnectTest`). Both are referenced by commit SHA. The token +server action lives in its own repo on purpose — it is general-purpose like +`dev-server-action` and is not bundled here. + When adding or renaming files that affect a CI stage, update the matching `ci.yml` `changes` filter in the same PR. For example, new build scripts, CMake files, package manifests, or reusable build workflows should be added to diff --git a/CMakeLists.txt b/CMakeLists.txt index be4a8e5d..e4aeaeb1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -93,6 +93,8 @@ file(MAKE_DIRECTORY ${PROTO_BINARY_DIR}) include(protobuf) # spdlog logging library (PRIVATE dependency). include(spdlog) +# nlohmann/json header-only library (PRIVATE dependency). +include(nlohmann_json) # Ensure protoc executable is found. if(TARGET protobuf::protoc) set(Protobuf_PROTOC_EXECUTABLE "$") @@ -392,6 +394,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 @@ -457,10 +464,18 @@ target_include_directories(livekit SYSTEM PRIVATE target_link_libraries(livekit PRIVATE spdlog::spdlog + nlohmann_json::nlohmann_json livekit_ffi ${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 d9fd9d71..55946393 100644 --- a/README.md +++ b/README.md @@ -187,9 +187,73 @@ 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) + +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 + +auto source = livekit::LiteralTokenSource::fromValue(url, jwt); +if (!room->connect(*source, options)) { + std::cerr << "Failed to connect to LiveKit\n"; + return 1; +} +``` + +**Endpoint** — POST (or GET) to your token server: + +```cpp +livekit::TokenRequestOptions request; +request.room_name = "my-room"; +request.participant_identity = "user-123"; + +livekit::TokenEndpointOptions endpoint_options; +endpoint_options.method = "POST"; // default; set to "GET" if your server requires it +endpoint_options.headers["Authorization"] = "Bearer your-api-token"; + +auto source = livekit::EndpointTokenSource::fromUrl("https://your-backend.example.com/token", + std::move(endpoint_options)); +if (!room->connect(*source, request, options)) { + return 1; +} +``` + +**Sandbox** (dev only) — LiveKit Cloud sandbox token server: + +```cpp +auto source = livekit::SandboxTokenSource::fromSandboxId( + "your-sandbox-id", + {}, + "https://cloud-api.livekit.io"); // optional base URL override +``` + +**Custom** — wrap your own fetch logic: + +```cpp +auto source = livekit::CustomTokenSource::fromCallback([](const livekit::TokenRequestOptions& options) { + std::promise> promise; + // fetch from your backend, then: + livekit::TokenSourceResponse details; + details.server_url = /* ... */; + details.participant_token = /* ... */; + 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 (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/cmake/nlohmann_json.cmake b/cmake/nlohmann_json.cmake new file mode 100644 index 00000000..f03f75c0 --- /dev/null +++ b/cmake/nlohmann_json.cmake @@ -0,0 +1,51 @@ +# cmake/nlohmann_json.cmake +# +# Windows: use vcpkg nlohmann-json +# macOS/Linux: vendored nlohmann/json via FetchContent (header-only) +# +# Exposes: +# - Target nlohmann_json::nlohmann_json (INTERFACE, header-only) +# +# nlohmann/json is a PRIVATE dependency of liblivekit: it is only included from +# implementation files under src/ and must never appear in a public header. +# Its include directories are marked SYSTEM so its single ~25k-line header does +# not trip -Wall/-Wextra/-Wpedantic or clang-tidy. + +include(FetchContent) +include(warnings) + +set(LIVEKIT_NLOHMANN_JSON_VERSION "3.12.0" CACHE STRING "Vendored nlohmann/json version") + +# --------------------------------------------------------------------------- +# Windows: use vcpkg +# --------------------------------------------------------------------------- +if(WIN32 AND LIVEKIT_USE_VCPKG) + find_package(nlohmann_json CONFIG REQUIRED) + if(TARGET nlohmann_json::nlohmann_json) + livekit_treat_as_external(nlohmann_json::nlohmann_json) + endif() + message(STATUS "Windows: using vcpkg nlohmann-json") + return() +endif() + +# --------------------------------------------------------------------------- +# macOS/Linux: vendored nlohmann/json via FetchContent +# --------------------------------------------------------------------------- +FetchContent_Declare( + livekit_nlohmann_json + URL "https://github.com/nlohmann/json/releases/download/v${LIVEKIT_NLOHMANN_JSON_VERSION}/json.tar.xz" + DOWNLOAD_EXTRACT_TIMESTAMP TRUE +) + +set(JSON_BuildTests OFF CACHE INTERNAL "") +set(JSON_Install OFF CACHE INTERNAL "") + +livekit_fetchcontent_makeavailable(livekit_nlohmann_json) + +# Header-only INTERFACE target: nothing to compile, but mark its includes as +# SYSTEM so warnings from json.hpp are suppressed in consuming targets. +if(TARGET nlohmann_json::nlohmann_json) + livekit_treat_as_external(nlohmann_json::nlohmann_json) +endif() + +message(STATUS "macOS/Linux: using vendored nlohmann/json v${LIVEKIT_NLOHMANN_JSON_VERSION}") 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/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 be76653f..197c516d 100644 --- a/include/livekit/room.h +++ b/include/livekit/room.h @@ -18,6 +18,7 @@ #include #include +#include #include #include #include @@ -28,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 { @@ -165,6 +167,22 @@ 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 using a fixed token source that supplies server URL and JWT. + /// + /// @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. + /// + /// @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. /// /// This method attempts a best-effort graceful disconnect of the room. If the room was connected prior, after @ref @@ -230,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 @@ -334,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/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/include/livekit/token_source.h b/include/livekit/token_source.h new file mode 100644 index 00000000..39f96b80 --- /dev/null +++ b/include/livekit/token_source.h @@ -0,0 +1,310 @@ +/* + * 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 "livekit/result.h" +#include "livekit/visibility.h" + +namespace livekit { + +/// @brief Credentials produced by a token source and consumed by @ref Room::connect. +/// +/// This is an output type: it is what a @ref TokenSourceFixed or +/// @ref TokenSourceConfigurable returns from @c fetch. Applications typically read +/// it rather than construct it. For static credentials, prefer +/// @ref LiteralTokenSource::fromValue, which takes the server URL and token +/// directly instead of requiring you to populate this struct. +/// +/// Mirrors the @c livekit.TokenSourceResponse protocol message: only the server +/// URL and participant token are carried; any additional fields a token server +/// returns are ignored. +struct TokenSourceResponse { + /// WebSocket URL of the LiveKit server. + std::string server_url; + + /// JWT access token for the participant. + std::string participant_token; +}; + +/// @brief Per-call options sent to configurable token sources (endpoint, sandbox, custom). +/// +/// All fields are optional. Unset or empty values are omitted from the token-server +/// request body. The token server embeds the provided values into the returned JWT; +/// @ref Room::connect does not read these options directly after fetch — the room, +/// identity, and grants come from the token. +/// +/// @note Which fields are honored depends on the token server. The LiveKit Cloud +/// sandbox token server auto-generates @c room_name, @c participant_identity, and +/// related fields when they are omitted. A project token endpoint typically accepts +/// the full set below, including agent dispatch via @c room_config. +struct TokenRequestOptions { + /// Target room name encoded into the token request. + /// + /// Set this when you need a stable room across reconnects or when coordinating + /// multiple clients in the same session. If omitted, many token servers (including + /// the sandbox) assign a new room name on each fetch, so repeat connections may + /// land in different rooms. + std::optional room_name; + + /// Participant display name shown in UIs and room rosters. + /// + /// Optional cosmetic label. Does not need to match @c participant_identity. + /// If omitted, the token server may generate one or leave it unset. + std::optional participant_name; + + /// Stable participant identity encoded into the JWT. + /// + /// Set this when the same logical user or device should reconnect with the same + /// identity (for example, @c "robot-a" in tests). If omitted, many token servers + /// assign a new identity on each fetch. + std::optional participant_identity; + + /// Opaque participant metadata string stored on the participant record. + /// + /// Often JSON. Passed through to the token server for inclusion in the JWT. + /// Optional unless your backend or agents depend on it. + std::optional participant_metadata; + + /// Key/value participant attributes encoded into the token request. + /// + /// Optional. Empty keys are omitted when serializing the request. Attribute + /// semantics are defined by your token server and application. + std::map participant_attributes; + + /// Name of a registered LiveKit agent to dispatch into the room. + /// + /// When set (alone or with @c agent_metadata / @c agent_deployment), the SDK + /// sends @c room_config.agents in the token request so the token server can + /// embed agent dispatch in the JWT. The named agent must already be deployed + /// and registered with the same @c agent_name; this does not run agent logic + /// in the client. + /// + /// @see https://docs.livekit.io/agents/server/agent-dispatch/ + std::optional agent_name; + + /// Opaque metadata passed to the dispatched agent job at startup. + /// + /// Often JSON. Applies to the remote agent worker, not the local participant + /// (use @c participant_metadata for that). Ignored unless @c agent_name is set + /// or another agent field triggers @c room_config serialization. + std::optional agent_metadata; + + /// LiveKit Cloud deployment to target for agent dispatch. + /// + /// Optional. When omitted or empty, the production deployment is used. + /// Only relevant when dispatching a named agent on LiveKit Cloud. + 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 Base interface for token sources that provide full credentials directly. +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 Base interface for token sources that generate credentials from request options. +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 Token source that returns credentials you already created yourself. +/// +/// Choose this when your app manually handles token creation/retrieval and you +/// want the SDK to consume those credentials as-is ("literal" workflow). This +/// class is ideal for quick prototypes, tests, and custom flows where you do not +/// want the SDK to issue token-generation requests. +class LIVEKIT_API LiteralTokenSource final : public TokenSourceFixed { +public: + /// @brief Create a token source from a static server URL and participant token. + /// + /// Each @ref fetch call returns the same credentials. + /// + /// @param server_url WebSocket URL of the LiveKit server. + /// @param participant_token JWT access token for the participant. + static std::unique_ptr fromValue(std::string server_url, std::string participant_token); + + /// @brief Create a token source from an async provider that returns full credentials. + /// + /// Use this overload when credentials are produced outside the SDK but fetched + /// lazily (for example, from your own cache or secure storage). + static std::unique_ptr fromProvider( + std::function>()> provider); + + std::future> fetch() override; + +private: + explicit LiteralTokenSource(TokenSourceResponse details); + explicit LiteralTokenSource(std::function>()> provider); + + TokenSourceResponse details_; + std::function>()> provider_; +}; + +/// @brief Token source that delegates token generation to your callback. +/// +/// Choose this when you already have an internal auth/token system and want to +/// integrate it with LiveKit's request options without adopting the standardized +/// token endpoint format. +class LIVEKIT_API CustomTokenSource final : public TokenSourceConfigurable { +public: + /// @brief Create a token source that delegates fetching to @p provider. + /// + /// The callback receives @ref TokenRequestOptions for each fetch and returns + /// @ref TokenSourceResponse produced by your application. + 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 Token source that calls your backend token endpoint over HTTP. +/// +/// Recommended for most production apps: keep API keys server-side, expose a +/// standardized token endpoint, and let the SDK request credentials with room, +/// participant, and agent options. +/// +/// @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. + /// + /// @param endpoint_url URL of your backend token endpoint. + /// @param options HTTP transport options (method, headers, timeout). + static std::unique_ptr fromUrl(std::string endpoint_url, TokenEndpointOptions options = {}); + + std::future> fetch(const TokenRequestOptions& options, + bool force_refresh = false) override; + +private: + // Network transport seam. Mirrors the internal HTTP client signature + // (returns the raw response body or an error string) so tests can inject a + // stub and assert the serialized request / parse a canned response without a + // live server. Defaults to the real HTTP client in production. + using HttpTransport = std::function( + const std::string& method, const std::string& url, const std::map& headers, + const std::string& json_body, std::chrono::milliseconds timeout)>; + + EndpointTokenSource(std::string endpoint_url, TokenEndpointOptions options, HttpTransport transport); + + Result fetchSync(const TokenRequestOptions& options) const; + + std::string endpoint_url_; + TokenEndpointOptions options_; + HttpTransport transport_; + + friend struct EndpointTokenSourceTestAccess; +}; + +/// @brief Token source that uses LiveKit Cloud's sandbox token server (development only). +/// +/// Use this for local development and quick testing when you do not yet have your +/// own backend token endpoint. Do not use in production. +/// +/// @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. + /// + /// @param sandbox_id Sandbox identifier from LiveKit Cloud (surrounding whitespace is trimmed). + /// @param options HTTP options (method, headers, timeout). + /// @param base_url LiveKit Cloud API base URL (default @c https://cloud-api.livekit.io). + static std::unique_ptr fromSandboxId( + const std::string& sandbox_id, TokenEndpointOptions options = {}, + const std::string& base_url = "https://cloud-api.livekit.io"); + + std::future> fetch(const TokenRequestOptions& options, + bool force_refresh = false) override; + +private: + SandboxTokenSource(const std::string& sandbox_id, TokenEndpointOptions options, const std::string& base_url); + + std::unique_ptr endpoint_; + + friend struct SandboxTokenSourceTestAccess; +}; + +/// @brief Decorator that adds JWT-aware caching to another configurable token source. +/// +/// Wrap @ref CustomTokenSource, @ref EndpointTokenSource, or +/// @ref SandboxTokenSource to reduce token fetch calls while still refreshing +/// when tokens expire or when @p force_refresh is requested. +class LIVEKIT_API CachingTokenSource final : public TokenSourceConfigurable { +public: + /// @brief Wrap @p inner with JWT-aware caching. + /// + /// Cached values are keyed by @ref TokenRequestOptions. + 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 3ad58938..d32bb2f9 100644 --- a/src/room.cpp +++ b/src/room.cpp @@ -91,6 +91,49 @@ void Room::setDelegate(RoomDelegate* delegate) { delegate_ = delegate; } +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; + } + + 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 { + details = token_source.fetch(request_options, false).get(); + } catch (const std::exception& e) { + LK_LOG_ERROR("Room::connect failed: failed to fetch token: {}", 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; + } + + 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) { TRACE_EVENT0("livekit", "Room::connect"); @@ -179,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; } @@ -198,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(); } @@ -243,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() @@ -325,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; { @@ -1202,6 +1253,17 @@ void Room::onEvent(const FfiEvent& event) { } break; } + 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); + } + break; + } case proto::RoomEvent::kEos: { if (subscription_thread_dispatcher_) { subscription_thread_dispatcher_->stopAll(); @@ -1225,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/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/CMakeLists.txt b/src/tests/CMakeLists.txt index 9583af73..b5439c32 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -327,6 +327,62 @@ if(STRESS_TEST_SOURCES) ) endif() +# ============================================================================ +# Standalone test utility binary +# ============================================================================ + +add_executable(token_source_tester + "${CMAKE_CURRENT_SOURCE_DIR}/token_source_tester.cpp" +) + +target_link_libraries(token_source_tester + PRIVATE + livekit +) + +target_include_directories(token_source_tester + PRIVATE + ${LIVEKIT_ROOT_DIR}/include +) + +target_compile_definitions(token_source_tester + PRIVATE + $<$:_USE_MATH_DEFINES> +) + +# Copy shared libraries to tester executable directory +if(WIN32) + add_custom_command(TARGET token_source_tester POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$/livekit_ffi.dll" + $ + COMMENT "Copying DLLs to token_source_tester directory" + ) +elseif(APPLE) + add_custom_command(TARGET token_source_tester POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$/liblivekit_ffi.dylib" + $ + COMMENT "Copying dylibs to token_source_tester directory" + ) +else() + add_custom_command(TARGET token_source_tester POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$/liblivekit_ffi.so" + $ + COMMENT "Copying shared libraries to token_source_tester directory" + ) +endif() + # ============================================================================ # Combined test target # ============================================================================ diff --git a/src/tests/integration/test_room.cpp b/src/tests/integration/test_room.cpp index 145d709d..dc84521e 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,55 @@ 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; + + auto token_source = LiteralTokenSource::fromValue(server_url_, token_); + 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; + TokenSourceResponse details; + details.server_url = server_url_; + details.participant_token = token_; + 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) { + if (!server_available_) { + GTEST_SKIP() << "LIVEKIT_URL and LIVEKIT_TOKEN_A not set"; + } + Room room; RoomOptions options; @@ -93,6 +146,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 +172,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; diff --git a/src/tests/integration/test_token_source_endpoint.cpp b/src/tests/integration/test_token_source_endpoint.cpp new file mode 100644 index 00000000..97f9e296 --- /dev/null +++ b/src/tests/integration/test_token_source_endpoint.cpp @@ -0,0 +1,90 @@ +/* + * 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 + +// Request serialization, response parsing, header passthrough, GET support, and +// sandbox URL/header resolution are covered by mocked unit tests in +// src/tests/unit/test_token_source.cpp. This file holds the end-to-end check +// that requires a real token endpoint plus a running livekit-server. + +namespace livekit::test { + +namespace { + +// Resolve the token-server /createToken URL from the environment. +// +// Primary: TOKEN_SERVER_PORT → http://127.0.0.1:/createToken (matches +// livekit-examples/token-server-node and tests.yml). +// +// Override: LIVEKIT_CREATE_TOKEN_URL supplies a full endpoint URL when the +// server is not on 127.0.0.1 or uses a non-standard path. +std::optional resolveCreateTokenUrl() { + if (const char* url = std::getenv("LIVEKIT_CREATE_TOKEN_URL"); url != nullptr && url[0] != '\0') { + return std::string(url); + } + if (const char* port = std::getenv("TOKEN_SERVER_PORT"); port != nullptr && port[0] != '\0') { + return std::string("http://127.0.0.1:") + port + "/createToken"; + } + return std::nullopt; +} + +} // namespace + +// End-to-end: requires a real token endpoint (token-server-node) pointed at a +// running livekit-server. CI sets TOKEN_SERVER_PORT and starts token-server-node +// with the local dev server's credentials; see tests.yml. +class TokenSourceEndpointConnectTest : public ::testing::Test { +protected: + void SetUp() override { + initialize(LogLevel::Info); + if (const auto url = resolveCreateTokenUrl()) { + create_token_url_ = *url; + endpoint_available_ = true; + } + } + + void TearDown() override { shutdown(); } + + bool endpoint_available_ = false; + std::string create_token_url_; +}; + +TEST_F(TokenSourceEndpointConnectTest, EndpointMintsConnectableToken) { + if (!endpoint_available_) { + GTEST_SKIP() << "TOKEN_SERVER_PORT or LIVEKIT_CREATE_TOKEN_URL not set"; + } + + auto source = EndpointTokenSource::fromUrl(create_token_url_); + + TokenRequestOptions request; + request.room_name = "cpp_endpoint_e2e"; + request.participant_identity = "cpp-endpoint-e2e"; + + Room room; + RoomOptions options; + ASSERT_TRUE(room.connect(*source, request, options)) << "endpoint-minted token should connect"; + EXPECT_FALSE(room.localParticipant().expired()); + EXPECT_EQ(room.connectionState(), ConnectionState::Connected); + EXPECT_TRUE(room.disconnect()); +} + +} // namespace livekit::test diff --git a/src/tests/token_source_tester.cpp b/src/tests/token_source_tester.cpp new file mode 100644 index 00000000..0b20cfd2 --- /dev/null +++ b/src/tests/token_source_tester.cpp @@ -0,0 +1,258 @@ +/* + * 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 +#include +#include + +namespace { + +using namespace std::chrono_literals; + +// How long to stay connected so participant join/leave events can be observed. +constexpr auto kObserveDuration = 5s; + +/// Render a participant display name, falling back to "" when empty. +std::string displayName(const std::string& name) { return name.empty() ? "" : name; } + +/// Standard one-line description of a participant (local or remote). +std::string formatParticipant(const livekit::Participant& participant) { + return "identity=" + participant.identity() + ", name=" + displayName(participant.name()); +} + +/// Minimal delegate that logs remote participant join/leave activity. +class ParticipantLogDelegate : public livekit::RoomDelegate { +public: + void onParticipantConnected(livekit::Room& /*room*/, const livekit::ParticipantConnectedEvent& event) override { + if (event.participant == nullptr) { + return; + } + std::cout << "Participant connected: " << formatParticipant(*event.participant) << "\n"; + } + + void onParticipantDisconnected(livekit::Room& /*room*/, const livekit::ParticipantDisconnectedEvent& event) override { + if (event.participant == nullptr) { + return; + } + std::cout << "Participant disconnected: identity=" << event.participant->identity() << "\n"; + } +}; + +void logRemoteParticipants(const livekit::Room& room) { + const auto participants = room.remoteParticipants(); + std::cout << "Remote participants currently in room: " << participants.size() << "\n"; + for (const auto& participant_weak : participants) { + if (const auto participant = participant_weak.lock()) { + std::cout << " - " << formatParticipant(*participant) << "\n"; + } + } +} + +bool runConnectedSession(livekit::Room& room) { + const auto local_participant = room.localParticipant().lock(); + if (!local_participant) { + std::cerr << "Failed to get local participant\n"; + return false; + } + std::cout << "Local participant info: " << formatParticipant(*local_participant) << "\n"; + + logRemoteParticipants(room); + + // Stay connected briefly so participant join/leave events are surfaced. + std::this_thread::sleep_for(kObserveDuration); + logRemoteParticipants(room); + + if (!room.disconnect()) { + std::cerr << "Failed to gracefully disconnect from room\n"; + return false; + } + + std::cout << "Disconnected from room\n"; + return true; +} + +bool literalTokenSourceConnect() { + const char* url_env = std::getenv("LIVEKIT_URL"); + const char* token_env = std::getenv("LIVEKIT_TOKEN_A"); + if (url_env == nullptr || url_env[0] == '\0') { + std::cerr << "LIVEKIT_URL not set\n"; + return false; + } + if (token_env == nullptr || token_env[0] == '\0') { + std::cerr << "LIVEKIT_TOKEN_A not set\n"; + return false; + } + + // Room and participant name/identity are encoded into the token generated by livekit-cli. + auto token_source = livekit::LiteralTokenSource::fromValue(url_env, token_env); + + livekit::Room room; + ParticipantLogDelegate delegate; + room.setDelegate(&delegate); + if (!room.connect(*token_source, livekit::RoomOptions())) { + std::cerr << "Failed to connect to room\n"; + return false; + } + std::cout << "Connected to room: " << room.roomInfo().name << " (literal token source)\n"; + + return runConnectedSession(room); +} + +std::string trimWhitespace(const std::string& value) { + std::size_t begin = 0; + std::size_t end = value.size(); + while (begin < end && std::isspace(static_cast(value[begin])) != 0) { + ++begin; + } + while (end > begin && std::isspace(static_cast(value[end - 1])) != 0) { + --end; + } + return value.substr(begin, end - begin); +} + +// Parses HTTP transport options for EndpointTokenSource from the environment. +// +// LIVEKIT_TOKEN_ENDPOINT_METHOD - optional HTTP method (default POST). +// LIVEKIT_TOKEN_ENDPOINT_HEADERS - optional newline-separated "Name: Value" pairs, +// e.g. "X-Sandbox-ID: my-id" or "Authorization: Bearer ...". +livekit::TokenEndpointOptions endpointOptionsFromEnv() { + livekit::TokenEndpointOptions options; + + if (const char* method_env = std::getenv("LIVEKIT_TOKEN_ENDPOINT_METHOD"); + method_env != nullptr && method_env[0] != '\0') { + options.method = method_env; + } + + const char* headers_env = std::getenv("LIVEKIT_TOKEN_ENDPOINT_HEADERS"); + if (headers_env == nullptr || headers_env[0] == '\0') { + return options; + } + + const std::string headers_text = headers_env; + std::size_t start = 0; + while (start <= headers_text.size()) { + const std::size_t newline = headers_text.find('\n', start); + const std::string line = + headers_text.substr(start, newline == std::string::npos ? std::string::npos : newline - start); + const std::size_t colon = line.find(':'); + if (colon != std::string::npos) { + const std::string name = trimWhitespace(line.substr(0, colon)); + const std::string value = trimWhitespace(line.substr(colon + 1)); + if (!name.empty()) { + options.headers[name] = value; + } + } + if (newline == std::string::npos) { + break; + } + start = newline + 1; + } + + return options; +} + +bool endpointTokenSourceConnect() { + std::string endpoint_url; + if (const char* endpoint_env = std::getenv("LIVEKIT_TOKEN_ENDPOINT"); + endpoint_env != nullptr && endpoint_env[0] != '\0') { + endpoint_url = endpoint_env; + } else { + const char* port_env = std::getenv("TOKEN_SERVER_PORT"); + const char* port = (port_env != nullptr && port_env[0] != '\0') ? port_env : "3000"; + endpoint_url = std::string("http://127.0.0.1:") + port + "/createToken"; + } + + auto endpoint_options = endpointOptionsFromEnv(); + std::cout << "Endpoint token source: " << endpoint_options.method << " " << endpoint_url << " (" + << endpoint_options.headers.size() << " custom header(s))\n"; + + auto token_source = livekit::EndpointTokenSource::fromUrl(endpoint_url, std::move(endpoint_options)); + + livekit::TokenRequestOptions options; + options.participant_identity = "robot-a"; + + livekit::Room room; + ParticipantLogDelegate delegate; + room.setDelegate(&delegate); + if (!room.connect(*token_source, options, livekit::RoomOptions())) { + std::cerr << "Failed to connect to room\n"; + return false; + } + std::cout << "Connected to room: " << room.roomInfo().name << " (endpoint token source)\n"; + + return runConnectedSession(room); +} + +bool sandboxTokenSourceConnect() { + const char* sandbox_id_env = std::getenv("LIVEKIT_SANDBOX_ID"); + if (sandbox_id_env == nullptr || sandbox_id_env[0] == '\0') { + std::cerr << "LIVEKIT_SANDBOX_ID not set\n"; + return false; + } + + // Sandbox token server: POSTs to cloud-api.livekit.io/api/v2/sandbox/connection-details + // with X-Sandbox-ID set from LIVEKIT_SANDBOX_ID. + auto token_source = livekit::SandboxTokenSource::fromSandboxId(sandbox_id_env); + + livekit::TokenRequestOptions options; + options.participant_identity = "robot-a"; + + // Optional agent dispatch: when LIVEKIT_AGENT_NAME is set, the request embeds + // room_config.agents so the token server dispatches a named agent into the room. + if (const char* agent_name_env = std::getenv("LIVEKIT_AGENT_NAME"); + agent_name_env != nullptr && agent_name_env[0] != '\0') { + options.agent_name = agent_name_env; + if (const char* agent_metadata_env = std::getenv("LIVEKIT_AGENT_METADATA"); + agent_metadata_env != nullptr && agent_metadata_env[0] != '\0') { + options.agent_metadata = agent_metadata_env; + } + std::cout << "Requesting sandbox token with agent dispatch: agent_name=" << *options.agent_name << "\n"; + } + + livekit::Room room; + ParticipantLogDelegate delegate; + room.setDelegate(&delegate); + if (!room.connect(*token_source, options, livekit::RoomOptions())) { + std::cerr << "Failed to connect to room\n"; + return false; + } + std::cout << "Connected to room: " << room.roomInfo().name << " (sandbox token source)\n"; + + return runConnectedSession(room); +} + +} // namespace + +int main() { + livekit::initialize(livekit::LogLevel::Info); + + // Swap the active connect function below to exercise a given token source. + // if (!literalTokenSourceConnect()) { + // if (!endpointTokenSourceConnect()) { + if (!sandboxTokenSourceConnect()) { + livekit::shutdown(); + return 1; + } + + livekit::shutdown(); + return 0; +} diff --git a/src/tests/unit/test_room.cpp b/src/tests/unit/test_room.cpp index 7a9ad193..2548bf45 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,81 @@ TEST_F(RoomTest, ConnectWithoutInitialize) { EXPECT_TRUE(room.remoteParticipants().empty()) << "Remote participants should be empty after failed connect"; } +TEST_F(RoomTest, ConnectWithLiteralTokenSourceEmptyCredentialsFails) { + Room room; + + auto source = LiteralTokenSource::fromValue("wss://localhost:7880", ""); + const bool result = room.connect(*source, RoomOptions()); + EXPECT_FALSE(result) << "Connecting with empty credentials should return false"; +} + +TEST_F(RoomTest, ConnectWithLiteralTokenSourceWithoutInitialize) { + livekit::shutdown(); + + Room room; + auto source = LiteralTokenSource::fromValue("wss://localhost:7880", "jwt-token"); + const bool result = room.connect(*source, RoomOptions()); + EXPECT_FALSE(result) << "Connecting without initializing should return false"; +} + +TEST_F(RoomTest, ConnectWithCustomTokenSourceThrowingFails) { + Room room; + + 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(*source, TokenRequestOptions{}, RoomOptions()); + EXPECT_FALSE(result) << "Connecting when token source throws should return false"; +} + +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 fetch_count = 0; + auto source = + LiteralTokenSource::fromProvider([&fetch_count]() -> std::future> { + ++fetch_count; + TokenSourceResponse 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(fetch_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 diff --git a/src/tests/unit/test_token_source.cpp b/src/tests/unit/test_token_source.cpp new file mode 100644 index 00000000..375889f3 --- /dev/null +++ b/src/tests/unit/test_token_source.cpp @@ -0,0 +1,473 @@ +/* + * 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 +#include +#include +#include +#include + +#include "token_source_internal.h" + +namespace livekit::test { + +namespace { + +// A non-expired unsigned JWT (alg=none, exp far in the future) used for stubbed +// token-endpoint responses. +constexpr const char* kValidToken = "eyJhbGciOiJub25lIn0.eyJleHAiOjk5OTk5OTk5OTk5fQ."; +constexpr const char* kServerUrl = "wss://localhost:7000"; + +// Captures the arguments the token source passed to the HTTP transport so tests +// can assert the serialized request (mirrors mocking global fetch in the JS SDK). +struct CapturedRequest { + std::string method; + std::string url; + std::map headers; + std::string body; + int calls = 0; +}; + +TokenRequestOptions exampleFetchOptions() { + TokenRequestOptions options; + options.room_name = "room name"; + options.participant_name = "participant name"; + options.participant_identity = "participant identity"; + options.participant_metadata = R"({"example": "metadata here"})"; + options.agent_name = "agent name"; + options.agent_metadata = R"({"example": "agent metadata here"})"; + return options; +} + +std::string successResponseJson(const std::string& extra_fields = "") { + return std::string(R"({"server_url":")") + kServerUrl + R"(","participant_token":")" + kValidToken + "\"" + + extra_fields + "}"; +} + +// Builds a transport that records the request into `capture` and returns `response`. +TokenSourceHttpTransport makeStubTransport(const std::shared_ptr& capture, + const Result& response) { + return [capture, response](const std::string& method, const std::string& url, + const std::map& headers, const std::string& body, + std::chrono::milliseconds) { + capture->method = method; + capture->url = url; + capture->headers = headers; + capture->body = body; + capture->calls += 1; + return response; + }; +} + +} // namespace + +TEST(TokenSourceEndpointMockTest, SendsAllProvidedFields) { + auto capture = std::make_shared(); + auto source = EndpointTokenSourceTestAccess::create( + "https://example.com/token", {}, + makeStubTransport(capture, Result::success(successResponseJson()))); + + const auto result = source->fetch(exampleFetchOptions()).get(); + ASSERT_TRUE(result); + EXPECT_EQ(result.value().server_url, kServerUrl); + EXPECT_EQ(result.value().participant_token, kValidToken); + + EXPECT_EQ(capture->calls, 1); + EXPECT_EQ(capture->method, "POST"); + EXPECT_EQ(capture->url, "https://example.com/token"); + EXPECT_NE(capture->body.find("\"room_name\":\"room name\""), std::string::npos); + EXPECT_NE(capture->body.find("\"participant_name\":\"participant name\""), std::string::npos); + EXPECT_NE(capture->body.find("\"participant_identity\":\"participant identity\""), std::string::npos); + EXPECT_NE(capture->body.find("\"participant_metadata\":"), std::string::npos); + // Agent options are packaged into room_config.agents (per the standard endpoint contract). + EXPECT_NE(capture->body.find("\"room_config\""), std::string::npos); + EXPECT_NE(capture->body.find("\"agents\""), std::string::npos); + EXPECT_NE(capture->body.find("\"agent_name\":\"agent name\""), std::string::npos); +} + +TEST(TokenSourceEndpointMockTest, SendsEmptyBodyWithNoOptions) { + auto capture = std::make_shared(); + auto source = EndpointTokenSourceTestAccess::create( + "https://example.com/token", {}, + makeStubTransport(capture, Result::success(successResponseJson()))); + + const auto result = source->fetch({}).get(); + ASSERT_TRUE(result); + EXPECT_EQ(capture->body, "{}"); +} + +TEST(TokenSourceEndpointMockTest, SendsOnlyProvidedFields) { + auto capture = std::make_shared(); + auto source = EndpointTokenSourceTestAccess::create( + "https://example.com/token", {}, + makeStubTransport(capture, Result::success(successResponseJson()))); + + TokenRequestOptions options; + options.room_name = "my-room"; + const auto result = source->fetch(options).get(); + ASSERT_TRUE(result); + EXPECT_NE(capture->body.find("\"room_name\":\"my-room\""), std::string::npos); + // No agent fields were provided, so room_config must be omitted entirely. + EXPECT_EQ(capture->body.find("room_config"), std::string::npos); +} + +TEST(TokenSourceEndpointMockTest, MergesCustomHeaders) { + auto capture = std::make_shared(); + TokenEndpointOptions endpoint_options; + endpoint_options.headers["Authorization"] = "Bearer my-token"; + endpoint_options.headers["X-Custom"] = "value"; + + auto source = EndpointTokenSourceTestAccess::create( + "https://example.com/token", std::move(endpoint_options), + makeStubTransport(capture, Result::success(successResponseJson()))); + + const auto result = source->fetch(exampleFetchOptions()).get(); + ASSERT_TRUE(result); + EXPECT_EQ(capture->headers["Authorization"], "Bearer my-token"); + EXPECT_EQ(capture->headers["X-Custom"], "value"); +} + +TEST(TokenSourceEndpointMockTest, FailsOnTransportError) { + auto capture = std::make_shared(); + auto source = EndpointTokenSourceTestAccess::create( + "https://example.com/token", {}, + makeStubTransport(capture, Result::failure("token server returned status 403"))); + + const auto result = source->fetch(exampleFetchOptions()).get(); + ASSERT_FALSE(result); + EXPECT_NE(result.error().message.find("403"), std::string::npos); +} + +TEST(TokenSourceEndpointMockTest, ParsesCamelCaseResponse) { + auto capture = std::make_shared(); + const std::string camel = std::string(R"({"serverUrl":")") + kServerUrl + R"(","participantToken":")" + kValidToken + + R"(","participantName":"Alice"})"; + auto source = EndpointTokenSourceTestAccess::create( + "https://example.com/token", {}, makeStubTransport(capture, Result::success(camel))); + + const auto result = source->fetch({}).get(); + ASSERT_TRUE(result); + EXPECT_EQ(result.value().server_url, kServerUrl); + EXPECT_EQ(result.value().participant_token, kValidToken); +} + +TEST(TokenSourceEndpointMockTest, IgnoresUnknownResponseFields) { + auto capture = std::make_shared(); + auto source = EndpointTokenSourceTestAccess::create( + "https://example.com/token", {}, + makeStubTransport(capture, Result::success( + successResponseJson(R"(,"some_future_field":"ignored","another_unknown":42)")))); + + const auto result = source->fetch(exampleFetchOptions()).get(); + ASSERT_TRUE(result); + EXPECT_EQ(result.value().server_url, kServerUrl); + EXPECT_EQ(result.value().participant_token, kValidToken); +} + +TEST(TokenSourceEndpointMockTest, FailsOnMalformedResponse) { + auto capture = std::make_shared(); + auto source = EndpointTokenSourceTestAccess::create( + "https://example.com/token", {}, + makeStubTransport(capture, Result::success("this-is-not-json"))); + + const auto result = source->fetch({}).get(); + ASSERT_FALSE(result); +} + +TEST(TokenSourceEndpointMockTest, SupportsGetMethod) { + auto capture = std::make_shared(); + TokenEndpointOptions endpoint_options; + endpoint_options.method = "GET"; + auto source = EndpointTokenSourceTestAccess::create( + "https://example.com/token", std::move(endpoint_options), + makeStubTransport(capture, Result::success(successResponseJson()))); + + const auto result = source->fetch({}).get(); + ASSERT_TRUE(result); + EXPECT_EQ(capture->method, "GET"); +} + +TEST(TokenSourceSandboxMockTest, SetsSandboxHeaderAndResolvesUrl) { + auto capture = std::make_shared(); + auto source = SandboxTokenSourceTestAccess::create( + " sandbox-123 ", {}, "https://cloud-api.livekit.io", + makeStubTransport(capture, Result::success(successResponseJson()))); + + const auto result = source->fetch({}).get(); + ASSERT_TRUE(result); + EXPECT_EQ(capture->url, "https://cloud-api.livekit.io/api/v2/sandbox/connection-details"); + EXPECT_EQ(capture->headers["X-Sandbox-ID"], "sandbox-123"); +} + +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"); +} + +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"); +} + +TEST(TokenSourceJsonTest, ParseResponseInvalidJsonFails) { + const auto result = parseTokenSourceResponseJson("this-is-not-json"); + ASSERT_FALSE(result); + EXPECT_EQ(result.error().message, "token server response missing server_url"); +} + +TEST(TokenSourceJsonTest, ParseResponseMissingParticipantTokenFails) { + const std::string json = R"({"server_url":"wss://example.livekit.io"})"; + const auto result = parseTokenSourceResponseJson(json); + ASSERT_FALSE(result); + EXPECT_EQ(result.error().message, "token server response missing participant_token"); +} + +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(TokenSourceJwtTest, UnparseableTokenIsInvalid) { EXPECT_FALSE(isParticipantTokenValid("not-a-jwt")); } + +TEST(TokenSourceFactoryTest, LiteralTokenSourceReturnsDetails) { + const std::string server_url = "wss://example.livekit.io"; + const std::string participant_token = "jwt-token"; + + auto source = LiteralTokenSource::fromValue(server_url, participant_token); + const auto result = source->fetch().get(); + ASSERT_TRUE(result); + EXPECT_EQ(result.value().server_url, server_url); + EXPECT_EQ(result.value().participant_token, 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; + TokenSourceResponse 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; + TokenSourceResponse 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; + TokenSourceResponse 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); +} + +TEST(TokenSourceFactoryTest, CachingTokenSourceRefetchesWhenOptionsChange) { + std::atomic fetch_count{0}; + auto inner = CustomTokenSource::fromCallback( + [&fetch_count](const TokenRequestOptions&) -> std::future> { + ++fetch_count; + TokenSourceResponse 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 first_request; + first_request.room_name = "room-a"; + TokenRequestOptions second_request; + second_request.room_name = "room-b"; + + (void)cached->fetch(first_request).get(); + (void)cached->fetch(second_request).get(); + EXPECT_EQ(fetch_count.load(), 2); +} + +TEST(TokenSourceFactoryTest, CachingTokenSourceRefetchesWhenTokenExpired) { + std::atomic fetch_count{0}; + auto inner = CustomTokenSource::fromCallback( + [&fetch_count](const TokenRequestOptions&) -> std::future> { + const int count = ++fetch_count; + TokenSourceResponse details; + details.server_url = "wss://example.livekit.io"; + details.participant_token = + (count == 1) ? "eyJhbGciOiJub25lIn0.eyJleHAiOjF9." : "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(), 2); + EXPECT_NE(first.value().participant_token, second.value().participant_token); +} + +TEST(TokenSourceFactoryTest, CachingTokenSourceRefetchesWhenTokenUnparseable) { + std::atomic fetch_count{0}; + auto inner = CustomTokenSource::fromCallback( + [&fetch_count](const TokenRequestOptions&) -> std::future> { + const int count = ++fetch_count; + TokenSourceResponse details; + details.server_url = "wss://example.livekit.io"; + details.participant_token = (count == 1) ? "not-a-jwt" : "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(), 2); +} + +TEST(TokenSourceFactoryTest, CachingTokenSourceSerializesConcurrentFetches) { + std::atomic fetch_count{0}; + std::atomic concurrent_calls{0}; + std::atomic max_concurrent_calls{0}; + + auto inner = CustomTokenSource::fromCallback( + [&fetch_count, &concurrent_calls, &max_concurrent_calls]( + const TokenRequestOptions&) -> std::future> { + ++fetch_count; + const int active = ++concurrent_calls; + int observed_max = max_concurrent_calls.load(); + while (active > observed_max && !max_concurrent_calls.compare_exchange_weak(observed_max, active)) { + } + + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + + TokenSourceResponse details; + details.server_url = "wss://example.livekit.io"; + details.participant_token = "eyJhbGciOiJub25lIn0.eyJleHAiOjk5OTk5OTk5OTk5fQ."; + std::promise> promise; + promise.set_value(Result::success(details)); + --concurrent_calls; + return promise.get_future(); + }); + + auto cached = CachingTokenSource::wrap(std::move(inner)); + TokenRequestOptions request; + request.room_name = "concurrent-room"; + + std::vector threads; + threads.reserve(4); + for (int i = 0; i < 4; ++i) { + threads.emplace_back([&cached, &request]() { (void)cached->fetch(request).get(); }); + } + for (auto& thread : threads) { + thread.join(); + } + + EXPECT_EQ(fetch_count.load(), 1); + EXPECT_EQ(max_concurrent_calls.load(), 1); +} + +} // namespace livekit::test diff --git a/src/token_source.cpp b/src/token_source.cpp new file mode 100644 index 00000000..37b781d9 --- /dev/null +++ b/src/token_source.cpp @@ -0,0 +1,264 @@ +/* + * 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 "livekit/token_source.h" + +#include +#include +#include +#include +#include + +#include "token_source_internal.h" + +namespace livekit { +namespace { + +using TokenSourceResult = Result; +using TokenSourceFuture = std::future; + +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; +} + +TokenSourceFuture makeFailedFuture(std::string message) { + std::promise promise; + promise.set_value(TokenSourceResult::failure(TokenSourceError{std::move(message)})); + return promise.get_future(); +} + +template +TokenSourceFuture runAsyncTokenSource(std::string context, WorkFn&& work_fn) { + try { + return std::async(std::launch::async, + [context = std::move(context), work_fn = std::forward(work_fn)]() mutable { + try { + return work_fn(); + } catch (const std::exception& e) { + return TokenSourceResult::failure(TokenSourceError{context + ": " + std::string(e.what())}); + } catch (...) { + return TokenSourceResult::failure(TokenSourceError{context + ": unknown exception"}); + } + }); + } catch (const std::exception& e) { + return makeFailedFuture(context + ": failed to start async work: " + std::string(e.what())); + } catch (...) { + return makeFailedFuture(context + ": failed to start async work: unknown exception"); + } +} + +std::string trimSandboxId(const std::string& sandbox_id) { + const auto is_space = [](unsigned char ch) { return std::isspace(ch) != 0; }; + const auto begin = std::find_if_not(sandbox_id.begin(), sandbox_id.end(), is_space); + const auto end = std::find_if_not(sandbox_id.rbegin(), sandbox_id.rend(), is_space).base(); + if (begin >= end) { + return {}; + } + return std::string(begin, end); +} + +std::string joinUrlPath(const std::string& base_url, const std::string& path) { + if (base_url.empty()) { + return path; + } + if (base_url.back() == '/') { + return base_url + (path.empty() || path.front() == '/' ? path.substr(path.front() == '/' ? 1 : 0) : path); + } + if (path.empty()) { + return base_url; + } + if (path.front() == '/') { + return base_url + path; + } + return base_url + "/" + path; +} + +struct ResolvedSandboxEndpoint { + std::string url; + TokenEndpointOptions options; +}; + +// Apply the sandbox header and resolve the connection-details URL shared by the +// production and test-only sandbox factories. +ResolvedSandboxEndpoint resolveSandboxEndpoint(const std::string& sandbox_id, TokenEndpointOptions options, + const std::string& base_url) { + options.headers["X-Sandbox-ID"] = trimSandboxId(sandbox_id); + return {joinUrlPath(base_url, "/api/v2/sandbox/connection-details"), std::move(options)}; +} + +} // namespace + +TokenSourceFixed::~TokenSourceFixed() = default; + +TokenSourceConfigurable::~TokenSourceConfigurable() = default; + +std::unique_ptr LiteralTokenSource::fromValue(std::string server_url, + std::string participant_token) { + TokenSourceResponse details; + details.server_url = std::move(server_url); + details.participant_token = std::move(participant_token); + 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(TokenSourceResponse 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), &tokenSourceHttpRequest)); +} + +EndpointTokenSource::EndpointTokenSource(std::string endpoint_url, TokenEndpointOptions options, + HttpTransport transport) + : endpoint_url_(std::move(endpoint_url)), options_(std::move(options)), transport_(std::move(transport)) {} + +std::unique_ptr EndpointTokenSourceTestAccess::create(std::string endpoint_url, + TokenEndpointOptions options, + TokenSourceHttpTransport transport) { + return std::unique_ptr( + new EndpointTokenSource(std::move(endpoint_url), std::move(options), std::move(transport))); +} + +std::future> EndpointTokenSource::fetch( + const TokenRequestOptions& options, bool /*force_refresh*/) { + std::shared_ptr options_snapshot; + try { + options_snapshot = std::make_shared(options); + } catch (const std::exception& e) { + return makeFailedFuture("token source endpoint fetch failed: failed to copy request options: " + + std::string(e.what())); + } catch (...) { + return makeFailedFuture("token source endpoint fetch failed: failed to copy request options: unknown exception"); + } + + return runAsyncTokenSource("token source endpoint fetch failed", + [this, options_snapshot]() { return fetchSync(*options_snapshot); }); +} + +Result EndpointTokenSource::fetchSync(const TokenRequestOptions& options) const { + const std::string request_json = buildTokenSourceRequestJson(options); + auto headers = options_.headers; + auto http_result = transport_(options_.method, 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(const std::string& sandbox_id, + TokenEndpointOptions options, + const std::string& base_url) { + return std::unique_ptr(new SandboxTokenSource(sandbox_id, std::move(options), base_url)); +} + +SandboxTokenSource::SandboxTokenSource(const std::string& sandbox_id, TokenEndpointOptions options, + const std::string& base_url) { + auto resolved = resolveSandboxEndpoint(sandbox_id, std::move(options), base_url); + endpoint_ = EndpointTokenSource::fromUrl(std::move(resolved.url), std::move(resolved.options)); +} + +std::unique_ptr SandboxTokenSourceTestAccess::create(const std::string& sandbox_id, + TokenEndpointOptions options, + const std::string& base_url, + TokenSourceHttpTransport transport) { + auto source = std::unique_ptr(new SandboxTokenSource(sandbox_id, options, base_url)); + auto resolved = resolveSandboxEndpoint(sandbox_id, std::move(options), base_url); + source->endpoint_ = + EndpointTokenSourceTestAccess::create(std::move(resolved.url), std::move(resolved.options), std::move(transport)); + return source; +} + +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) { + std::shared_ptr options_snapshot; + try { + options_snapshot = std::make_shared(options); + } catch (const std::exception& e) { + return makeFailedFuture("token source cache fetch failed: failed to copy request options: " + + std::string(e.what())); + } catch (...) { + return makeFailedFuture("token source cache fetch failed: failed to copy request options: unknown exception"); + } + + return runAsyncTokenSource("token source cache fetch failed", [this, options_snapshot, force_refresh]() { + const std::scoped_lock lock(mutex_); + if (!force_refresh && cached_details_.has_value() && cached_options_.has_value() && + tokenRequestOptionsEqual(*cached_options_, *options_snapshot) && + isParticipantTokenValid(cached_details_->participant_token)) { + return TokenSourceResult::success(*cached_details_); + } + + auto result = inner_->fetch(*options_snapshot, force_refresh).get(); + if (result) { + cached_options_ = *options_snapshot; + 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..93e6e9f9 --- /dev/null +++ b/src/token_source_http.cpp @@ -0,0 +1,242 @@ +/* + * 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 "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 + +std::string normalizeHttpMethod(std::string method) { + if (method.empty()) { + return "POST"; + } + std::transform(method.begin(), method.end(), method.begin(), + [](unsigned char ch) { return static_cast(std::toupper(ch)); }); + return method; +} + +#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 winHttpRequest(const std::string& method, 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); + const std::wstring wide_method = toWide(normalizeHttpMethod(method)); + + 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, wide_method.c_str(), 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 tokenSourceHttpRequest(const std::string& method, const std::string& url, + const std::map& headers, + const std::string& json_body, + std::chrono::milliseconds timeout) { +#if defined(_WIN32) + return winHttpRequest(method, url, headers, json_body, timeout); +#else + CURL* curl = curl_easy_init(); + if (curl == nullptr) { + return Result::failure("curl_easy_init failed"); + } + + const std::string normalized_method = normalizeHttpMethod(method); + + std::string response_body; + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, normalized_method.c_str()); + 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) { + 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) { + 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 returned HTTP code " << status_code << ": "; + if (!response_body.empty()) { + message << response_body; + } else { + message << ""; + } + 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..facaf68e --- /dev/null +++ b/src/token_source_internal.h @@ -0,0 +1,68 @@ +/* + * 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 "livekit/result.h" +#include "livekit/token_source.h" +#include "livekit/visibility.h" + +namespace livekit { + +/// @brief Perform an HTTPS/HTTP request with a JSON body (internal). +LIVEKIT_INTERNAL_API Result tokenSourceHttpRequest( + const std::string& method, const std::string& url, const std::map& headers, + const std::string& json_body, std::chrono::milliseconds timeout); + +/// @brief Signature of the HTTP transport seam injected by tests. +using TokenSourceHttpTransport = std::function( + const std::string& method, const std::string& url, const std::map& headers, + const std::string& json_body, std::chrono::milliseconds timeout)>; + +/// @brief Test-only constructor access for @ref EndpointTokenSource. +/// +/// Lets unit tests inject a stub transport so request serialization and +/// response parsing can be exercised without a live server. +struct LIVEKIT_INTERNAL_API EndpointTokenSourceTestAccess { + static std::unique_ptr create(std::string endpoint_url, TokenEndpointOptions options, + TokenSourceHttpTransport transport); +}; + +/// @brief Test-only constructor access for @ref SandboxTokenSource. +/// +/// Builds a sandbox source whose underlying endpoint uses an injected stub +/// transport, so the X-Sandbox-ID header and resolved URL can be asserted. +struct LIVEKIT_INTERNAL_API SandboxTokenSourceTestAccess { + static std::unique_ptr create(const std::string& sandbox_id, TokenEndpointOptions options, + const std::string& base_url, TokenSourceHttpTransport transport); +}; + +/// @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 TokenSourceResponse. +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..42379f3c --- /dev/null +++ b/src/token_source_json.cpp @@ -0,0 +1,113 @@ +/* + * 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 { + +using nlohmann::json; + +// Read a string field, accepting either the snake_case or camelCase spelling. +// Returns the value only when it is a non-empty JSON string. +std::optional readStringField(const json& obj, const char* snake_key, const char* camel_key) { + for (const char* key : {snake_key, camel_key}) { + const auto it = obj.find(key); + if (it != obj.end() && it->is_string()) { + std::string value = it->get(); + if (!value.empty()) { + return value; + } + } + } + return std::nullopt; +} + +} // namespace + +std::string buildTokenSourceRequestJson(const TokenRequestOptions& options) { + json body = json::object(); + + const auto set_optional = [&body](const char* key, const std::optional& value) { + if (value.has_value() && !value->empty()) { + body[key] = *value; + } + }; + + set_optional("room_name", options.room_name); + set_optional("participant_name", options.participant_name); + set_optional("participant_identity", options.participant_identity); + set_optional("participant_metadata", options.participant_metadata); + + if (!options.participant_attributes.empty()) { + json attributes = json::object(); + for (const auto& [key, value] : options.participant_attributes) { + if (!key.empty()) { + attributes[key] = value; + } + } + body["participant_attributes"] = std::move(attributes); + } + + if (options.agent_name.has_value() || options.agent_metadata.has_value() || options.agent_deployment.has_value()) { + json agent = json::object(); + if (options.agent_name.has_value() && !options.agent_name->empty()) { + agent["agent_name"] = *options.agent_name; + } + if (options.agent_metadata.has_value() && !options.agent_metadata->empty()) { + agent["metadata"] = *options.agent_metadata; + } + if (options.agent_deployment.has_value() && !options.agent_deployment->empty()) { + agent["deployment"] = *options.agent_deployment; + } + body["room_config"] = json{{"agents", json::array({std::move(agent)})}}; + } + + return body.dump(); +} + +Result parseTokenSourceResponseJson(const std::string& json_text) { + // Parse without exceptions: malformed input yields a discarded value, which we + // treat the same as a response missing the required fields. + const json parsed = json::parse(json_text, nullptr, /*allow_exceptions=*/false); + + TokenSourceResponse details; + + if (parsed.is_object()) { + if (const auto server_url = readStringField(parsed, "server_url", "serverUrl")) { + details.server_url = *server_url; + } + if (const auto participant_token = readStringField(parsed, "participant_token", "participantToken")) { + details.participant_token = *participant_token; + } + } + + if (details.server_url.empty()) { + return Result::failure( + TokenSourceError{"token server response missing server_url"}); + } + if (details.participant_token.empty()) { + return Result::failure( + TokenSourceError{"token server response missing 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..a17d6371 --- /dev/null +++ b/src/token_source_jwt.cpp @@ -0,0 +1,141 @@ +/* + * 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 (const 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 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()); +} + +// Read an integer-valued JWT claim (e.g. "nbf"/"exp"). JWT numeric date claims +// are seconds since the epoch; non-integer or absent claims return nullopt. +std::optional readNumericClaim(const nlohmann::json& payload, const char* key) { + const auto it = payload.find(key); + if (it == payload.end() || !it->is_number()) { + return std::nullopt; + } + return it->get(); +} + +} // namespace + +bool isParticipantTokenValid(const std::string& participant_token) { + const auto payload_json = extractJwtPayloadJson(participant_token); + if (!payload_json.has_value()) { + return false; + } + + const nlohmann::json payload = nlohmann::json::parse(*payload_json, nullptr, /*allow_exceptions=*/false); + if (!payload.is_object()) { + return false; + } + + const auto now_seconds = + std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); + + const auto nbf = readNumericClaim(payload, "nbf"); + if (nbf.has_value() && *nbf > now_seconds) { + return false; + } + + const auto exp = readNumericClaim(payload, "exp"); + if (exp.has_value()) { + constexpr std::int64_t kExpiryBufferSeconds = 60; + if (*exp <= now_seconds + kExpiryBufferSeconds) { + return false; + } + } + + return true; +} + +} // namespace livekit