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 ca8c499c..afc484b3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -115,6 +115,7 @@ jobs: libssl-dev \ libprotobuf-dev protobuf-compiler \ libabsl-dev \ + libcurl4-openssl-dev \ libwayland-dev libdecor-0-dev \ jq @@ -208,7 +209,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 @@ -248,12 +248,22 @@ jobs: fi lk --version + - name: Start token server + if: matrix.e2e-testing + id: token_server + uses: livekit/token-server-action@a1e42c649a1998d5b224f5102f252c07762024f0 # v0.0.1 + with: + livekit-url: ws://localhost:7880 + api-key: devkey + api-secret: secret + - name: Run integration tests if: matrix.e2e-testing timeout-minutes: 10 shell: bash env: RUST_LOG: "metrics=debug" + LIVEKIT_CREATE_TOKEN_URL: ${{ steps.token_server.outputs.token-url }} run: | set -euo pipefail source .token_helpers/set_data_track_test_tokens.bash @@ -265,6 +275,11 @@ jobs: shell: bash run: tail -n 500 "${{ steps.livekit_server.outputs.log-path }}" || true + - name: Dump token server 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() @@ -329,6 +344,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..21f7ddbf 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 `livekit/token-server-action` token server pointed at the local dev `livekit-server`. The action exposes its `/createToken` endpoint as a `token-url` output; `tests.yml` passes that to the integration test step as `LIVEKIT_CREATE_TOKEN_URL`, which the test reads to locate the endpoint. 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/token-server-action` (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/CMakePresets.json b/CMakePresets.json index 3c863d7c..eaa05455 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -94,8 +94,7 @@ "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "LIVEKIT_BUILD_EXAMPLES": "ON", - "LIVEKIT_BUILD_TESTS": "OFF", - "VCPKG_MANIFEST_FEATURES": "examples" + "LIVEKIT_BUILD_TESTS": "OFF" } }, { @@ -107,8 +106,7 @@ "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", "LIVEKIT_BUILD_EXAMPLES": "ON", - "LIVEKIT_BUILD_TESTS": "OFF", - "VCPKG_MANIFEST_FEATURES": "examples" + "LIVEKIT_BUILD_TESTS": "OFF" } }, { @@ -216,8 +214,7 @@ "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "LIVEKIT_BUILD_EXAMPLES": "ON", - "LIVEKIT_BUILD_TESTS": "ON", - "VCPKG_MANIFEST_FEATURES": "examples" + "LIVEKIT_BUILD_TESTS": "ON" } }, { @@ -229,8 +226,7 @@ "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", "LIVEKIT_BUILD_EXAMPLES": "ON", - "LIVEKIT_BUILD_TESTS": "ON", - "VCPKG_MANIFEST_FEATURES": "examples" + "LIVEKIT_BUILD_TESTS": "ON" } }, { diff --git a/README.md b/README.md index d9fd9d71..592c62c9 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Use this SDK to add realtime video, audio and data features to your C++ app. By - [LiveKit docs](https://docs.livekit.io) - [SDK reference](https://docs.livekit.io/reference/client-sdk-cpp/) -- [Repository docs](./docs/README.md) +- [Repository docs](./docs/README.md) — building, [authentication](./docs/authentication.md), testing, logging, tracing ## Using the SDK @@ -187,9 +187,33 @@ 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). +### Generating tokens + +For local development, install [`livekit-cli`](https://docs.livekit.io/home/cli/cli-setup/) +and mint a participant JWT against a dev server (`livekit-server --dev` uses +`devkey` / `secret`): + +```bash +export LIVEKIT_URL=ws://localhost:7880 +export LIVEKIT_TOKEN=$(lk token create \ + --api-key devkey \ + --api-secret secret \ + -i my-participant \ + --join \ + --valid-for 24h \ + --room my-room \ + --grant '{"canPublish":true,"canSubscribe":true,"canPublishData":true}' \ + --token-only) +``` + +Pass `LIVEKIT_URL` and `LIVEKIT_TOKEN` into `Room::connect`, or use the SDK's +token source helpers for endpoint, sandbox, and custom backends. See +[docs/authentication.md](docs/authentication.md). + ## 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/docs/README.md b/docs/README.md index fd06e209..5e038112 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,10 @@ Additional documentation for the SDK. - [Building](building.md) — prerequisites, build scripts, CMake presets, vcpkg, Docker, integration into your CMake project, troubleshooting. +- [Authentication](authentication.md) — generating tokens, token source types + (initial connect only), and in-session token refresh. +- [Token lifecycle](token-lifecycle.md) — client vs. server responsibilities, + join `valid-for`, refresh TTL, and reconnect windows. - [Logging](logging.md) — compile-time vs runtime filtering, log levels, custom sinks (file, JSON, ROS2 `RCLCPP_*` macros). - [Tracing](tracing.md) — Chromium-format performance traces, viewing in diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 00000000..0e097b79 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,236 @@ +# Authentication + +LiveKit rooms require a **WebSocket URL** and a **participant JWT** to join. +This document covers how to obtain those credentials in C++ and how that +relates to in-session token refresh. + +## Initial connect vs. in-session refresh + +These are separate mechanisms: + +| | **Initial connect** | **In-session token refresh** | +|---|---|---| +| **When** | Before or at `Room::connect` | While already connected | +| **Who provides the JWT** | Your app, backend, or token server | LiveKit server (signal channel) | +| **C++ API** | `Room::connect(url, token, …)` or `Room::connect(token_source, …)` | `RoomDelegate::onTokenRefreshed` (informational) | +| **Rust / FFI** | JWT passed once via `connectAsync` | Handled internally; C++ receives a `TokenRefreshed` event | + +**Token sources are for initial connection only.** After a successful connect, +the Rust core owns the session JWT. The SDK applies server-pushed token updates +internally (for reconnect). Your token endpoint is **not** called again unless +**you** invoke `Room::connect` again (for example after a full disconnect). + +If you need the latest JWT after a server refresh, implement +`RoomDelegate::onTokenRefreshed` and store `TokenRefreshedEvent::token` in your +application. + +Cross-platform background: [LiveKit authentication docs](https://docs.livekit.io/frontends/build/authentication/). + +## Generating Tokens for Quick Development + +For local development against `livekit-server --dev`, install +[`livekit-cli`](https://docs.livekit.io/home/cli/cli-setup/) and mint a token: + +```bash +export LIVEKIT_URL=ws://localhost:7880 +export LIVEKIT_TOKEN=$(lk token create \ + --api-key devkey \ + --api-secret secret \ + -i my-participant \ + --join \ + --valid-for 24h \ + --room my-room \ + --grant '{"canPublish":true,"canSubscribe":true,"canPublishData":true}' \ + --token-only) +``` + +For integration tests that need two participants, see +[testing.md](testing.md#generating-tokens-for-the-test-suites) and the helper +script under `.token_helpers/`. + +## Direct connect (URL + token) + +When you already have credentials, pass them directly — no token source +required: + +```cpp +#include "livekit/livekit.h" + +livekit::initialize(livekit::LogLevel::Info); + +livekit::Room room; +livekit::RoomOptions options; + +if (!room.connect(url, token, options)) { + // handle failure +} +``` + +Use this for prototypes, tests, or when your app fetches the JWT outside the +SDK. + +## Token sources (initial connect) + +Token sources are C++ helpers that call `fetch()` to produce a +`TokenSourceResponse` (`server_url` + `participant_token`), then connect: + +```cpp +#include + +auto source = /* LiteralTokenSource, EndpointTokenSource, etc. */; +if (!room.connect(*source, options)) { /* fixed source */ } +// or, for configurable sources: +if (!room.connect(*source, request_options, options)) { /* ... */ } +``` + +`Room` does **not** retain the token source after connect. Calling `fetch()` +again only happens when you call `Room::connect` again with that source. + +### Fixed vs. configurable + +| Base class | `fetch` signature | Use when | +|---|---|---| +| `TokenSourceFixed` | `fetch()` | Credentials are fully determined without per-call options | +| `TokenSourceConfigurable` | `fetch(options, force_refresh)` | Room, identity, agent dispatch, etc. vary per connect | + +### Token source types + +| Type | Class | Typical use | +|---|---|---| +| **Literal** | `LiteralTokenSource` | Static URL + JWT, or lazy async provider | +| **Endpoint** | `EndpointTokenSource` | Production: HTTP token server on your backend | +| **Sandbox** | `SandboxTokenSource` | Development: LiveKit Cloud sandbox token server | +| **Custom** | `CustomTokenSource` | Your own async credential logic | +| **Caching** | `CachingTokenSource` | Decorator: JWT-aware cache around a configurable source | + +--- + +### Literal + +Static credentials you already have, or credentials loaded asynchronously +(keychain, secure storage, your auth service): + +```cpp +#include + +// Static URL + JWT +auto source = livekit::LiteralTokenSource::fromValue(url, jwt); +if (!room.connect(*source, options)) { + return 1; +} + +// Async provider (same contract as JS TokenSource.literal(async () => …)) +auto source2 = livekit::LiteralTokenSource::fromProvider([]() { + return std::async(std::launch::async, [] { + livekit::TokenSourceResponse details; + details.server_url = /* ... */; + details.participant_token = /* ... */; + return livekit::Result::success(details); + }); +}); +``` + +### Endpoint + +Recommended for production. Keeps API keys on your server; the SDK POSTs (or +GETs) to your token endpoint with optional room, participant, and agent fields. + +Request and response formats follow the +[standard token server contract](https://docs.livekit.io/frontends/build/authentication/endpoint/). + +```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 (development only) + +Uses the LiveKit Cloud sandbox token server. Do not use in production. + +```cpp +auto source = livekit::SandboxTokenSource::fromSandboxId( + "your-sandbox-id", + {}, + "https://cloud-api.livekit.io"); // optional base URL override + +livekit::TokenRequestOptions request; +request.agent_name = "my-agent"; // optional agent dispatch + +if (!room.connect(*source, request, options)) { + return 1; +} +``` + +See [sandbox token server docs](https://docs.livekit.io/frontends/build/authentication/sandbox-token-server/). + +### Custom + +Integrate an existing auth system without adopting the standard endpoint wire +format: + +```cpp +auto source = livekit::CustomTokenSource::fromCallback( + [](const livekit::TokenRequestOptions& options) + -> std::future> { + std::promise> promise; + livekit::TokenSourceResponse details; + details.server_url = /* ... */; + details.participant_token = /* encode options into your JWT ... */; + promise.set_value( + livekit::Result::success(details)); + return promise.get_future(); + }); +``` + +### Caching + +Wraps another configurable source and reuses a cached JWT when options match +and the token is still valid. `force_refresh = true` bypasses the cache on the +next `fetch()` — useful when calling `Room::connect` again, **not** for +server-pushed refresh during an active session. + +```cpp +auto inner = livekit::EndpointTokenSource::fromUrl("https://your-backend.example.com/token"); +auto source = livekit::CachingTokenSource::wrap(std::move(inner)); + +if (!room.connect(*source, request, options)) { + return 1; +} +``` + +## In-session token refresh + +During an active session, the LiveKit server may push an updated JWT over the +signal connection so the client can reconnect if the original join token +expires. The Rust core stores and applies this token automatically. + +C++ surfaces an optional notification: + +```cpp +class MyDelegate : public livekit::RoomDelegate { +public: + void onTokenRefreshed(livekit::Room&, const livekit::TokenRefreshedEvent& ev) override { + // ev.token — latest JWT the SDK is using internally + } +}; +``` + +This event is **informational**. It does not invoke your token source or token +endpoint. Automatic reconnect/resume while connected uses the internally stored +JWT only. + +See [token-lifecycle.md](token-lifecycle.md) for client vs. server +responsibilities, refresh timing, and how join `valid-for` relates to reconnect. diff --git a/docs/building.md b/docs/building.md index 60f08a71..79f4e6e5 100644 --- a/docs/building.md +++ b/docs/building.md @@ -157,7 +157,7 @@ cmake --build build --config Release # With examples: cmake -B build -S . ` -DCMAKE_TOOLCHAIN_FILE=$env:VCPKG_ROOT\scripts\buildsystems\vcpkg.cmake ` - -DLIVEKIT_BUILD_EXAMPLES=ON -DVCPKG_MANIFEST_FEATURES=examples + -DLIVEKIT_BUILD_EXAMPLES=ON cmake --build build --config Release ``` @@ -172,7 +172,7 @@ cmake --build build cmake -B build -S . \ -DCMAKE_TOOLCHAIN_FILE=/scripts/buildsystems/vcpkg.cmake \ -DCMAKE_BUILD_TYPE=Release \ - -DLIVEKIT_BUILD_EXAMPLES=ON -DVCPKG_MANIFEST_FEATURES=examples + -DLIVEKIT_BUILD_EXAMPLES=ON cmake --build build ``` diff --git a/docs/token-lifecycle.md b/docs/token-lifecycle.md new file mode 100644 index 00000000..deab9f35 --- /dev/null +++ b/docs/token-lifecycle.md @@ -0,0 +1,109 @@ +# Token lifecycle + +Succinct reference for how join credentials and in-session refresh interact in +the C++ SDK. For API examples, see [authentication.md](authentication.md). + +## Client responsibilities + +| Layer | Role | +|---|---| +| **Your app / backend** | Mints or stores the initial JWT (`lk token create`, server SDK, etc.). Keeps API secrets off the client in production. | +| **TokenSource** (C++ only) | Optional helper to `fetch()` `{ server_url, participant_token }` **before** connect. Types: literal, endpoint, sandbox, custom, caching. | +| **`Room::connect`** | Hands `(url, token)` to Rust via FFI once. Does not retain the token source. | +| **Rust core (FFI)** | Owns the session JWT after connect. Stores server-pushed refreshes internally and uses them for signal reconnect/resume. | +| **`RoomDelegate::onTokenRefreshed`** | Optional app notification when the server pushes a new JWT. Informational only — does not trigger TokenSource. | + +**TokenSource is initial-connect only.** It is not called on network blips, +automatic reconnect, or server refresh. To join again after a full disconnect, +call `Room::connect` (with or without a token source) explicitly. + +## Server responsibilities + +The LiveKit server pushes refreshed JWTs over the **signal WebSocket** +(`SignalResponse.refresh_token` in +[livekit_rtc.proto](https://github.com/livekit/protocol/blob/main/protobufs/livekit_rtc.proto)). + +Implementation (open-source server): + +1. **Immediately after join** — one refresh so clients with near-expiry join + tokens still get a fresh credential. +2. **Periodically while connected** — `tokenRefreshInterval` (currently + `5 * time.Minute`, hardcoded in + [roommanager.go](https://github.com/livekit/livekit/blob/master/pkg/service/roommanager.go#L61-L64)). +3. **On participant changes** — name, permissions, or metadata updates (see + [docs: Token refresh](https://docs.livekit.io/frontends/reference/tokens-grants/#token-refresh)). + +Each refresh re-mints a JWT with the **same grants/identity** and a new +`exp`. The client SDK applies it internally; C++ forwards +`RoomEvent::TokenRefreshed` to `onTokenRefreshed`. + +References: + +- Session loop: + [roommanager.go#L761-L774](https://github.com/livekit/livekit/blob/master/pkg/service/roommanager.go#L761-L774) +- Mint + send: + [refreshToken()](https://github.com/livekit/livekit/blob/master/pkg/service/roommanager.go#L1140-L1170), + [SendRefreshToken()](https://github.com/livekit/livekit/blob/master/pkg/rtc/participant_signal.go#L156-L157) + +## Join `valid-for` vs. refresh vs. reconnect + +Three different time concepts: + +| Concept | Who sets it | What it bounds | +|---|---|---| +| **Join token `valid-for`** | Your app when minting the initial JWT (`lk token create --valid-for`, server SDK `SetValidFor`, etc.) | Whether the **first** `Room::connect` is accepted. | +| **Refreshed token TTL** | Server (`tokenDefaultTTL = 10 * time.Minute` in [roommanager.go](https://github.com/livekit/livekit/blob/master/pkg/service/roommanager.go#L63), or remaining join-token lifetime if longer) | How long each **server-pushed** refresh JWT remains valid. | +| **Refresh interval** | Server only (`5 * time.Minute`; not documented as a public SLA) | How often the server **re-pushes** while the session stays up. | + +### How they interact + +``` +Join Connected session Disconnect + | | | + |-- connect(join JWT) -------->| | + | |-- server: immediate refresh -->| (onTokenRefreshed) + | |-- server: refresh every ~5m --->| (optional repeats) + | |-- SDK stores latest JWT ------>| (Rust internal) + | | | + |<-------- resume / signal reconnect uses latest stored JWT ---| + | | | + | X-- join JWT may be expired ------| OK if refresh received +``` + +**While connected:** Expiration of the **original** join token does not drop +the session. The server keeps issuing refreshed JWTs; the SDK keeps the latest +one for reconnect. Per +[Tokens & grants](https://docs.livekit.io/frontends/reference/tokens-grants/#token-refresh): +*"Expiration time only impacts the initial connection, and not subsequent +reconnects."* + +**On disconnect/reconnect:** + +- **Automatic reconnect/resume** (still in a session): Rust uses the **last + server-refreshed** JWT, not your token endpoint and not TokenSource. +- **New `Room::connect` after teardown**: You need a **new join credential** + (app, TokenSource, or `lk`/server SDK). Server refresh does not substitute + for this path. + +**Reconnect window:** After a network drop, reconnect succeeds if the client +received at least one refresh whose `exp` has not passed. Refreshed tokens +live up to **10 minutes** (or longer if the join token had more remaining +lifetime). A long offline gap can fail reconnect even though the session felt +"continuous" — there was no refresh while disconnected. + +**Practical guidance:** + +- Short join `valid-for` (e.g. 5m) is fine for connected clients; server + refresh extends the effective reconnect horizon. +- Very short join tokens still work if the server sends its **immediate** + post-join refresh before the join JWT expires. +- For manual rejoin after disconnect, mint a new token or call TokenSource + again; do not rely on `onTokenRefreshed` alone unless your app cached the + latest refreshed JWT. + +## Verifying refresh in this repo + +Integration test `RoomTest.ServerRefreshTokenFiresDelegate` in +`src/tests/integration/test_room.cpp` asserts `onTokenRefreshed` fires shortly +after connect against a local `livekit-server --dev` (immediate server refresh, +no 5-minute wait). 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..a1cc609a 100644 --- a/include/livekit/room.h +++ b/include/livekit/room.h @@ -28,6 +28,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 { @@ -145,7 +146,7 @@ class LIVEKIT_API Room { /// room.setDelegate(&del); void setDelegate(RoomDelegate* delegate); - /// Connect to a LiveKit room using the given URL and token, applying the + /// Connect to a LiveKit room using the given URL and token, applying the /// supplied connection options. /// /// @param url WebSocket URL of the LiveKit server. @@ -165,6 +166,24 @@ 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. + /// + /// @see TokenSourceFixed + /// @param token_source @ref TokenSourceFixed 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. + /// + /// @see TokenSourceConfigurable + /// @param token_source @ref TokenSourceConfigurable 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 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..195d8d24 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"); @@ -1202,6 +1245,13 @@ void Room::onEvent(const FfiEvent& event) { } break; } + case proto::RoomEvent::kTokenRefreshed: { + const TokenRefreshedEvent ev = fromProto(re.token_refreshed()); + if (delegate_snapshot) { + delegate_snapshot->onTokenRefreshed(*this, ev); + } + break; + } case proto::RoomEvent::kEos: { if (subscription_thread_dispatcher_) { subscription_thread_dispatcher_->stopAll(); diff --git a/src/room_proto_converter.cpp b/src/room_proto_converter.cpp index 53b49c59..59561592 100644 --- a/src/room_proto_converter.cpp +++ b/src/room_proto_converter.cpp @@ -323,6 +323,12 @@ ReconnectingEvent fromProto(const proto::Reconnecting& /*in*/) { return Reconnec ReconnectedEvent fromProto(const proto::Reconnected& /*in*/) { return ReconnectedEvent{}; } +TokenRefreshedEvent fromProto(const proto::TokenRefreshed& in) { + TokenRefreshedEvent ev; + ev.token = in.token(); + return ev; +} + RoomEosEvent fromProto(const proto::RoomEOS& /*in*/) { return RoomEosEvent{}; } DataStreamHeaderReceivedEvent fromProto(const proto::DataStreamHeaderReceived& in) { diff --git a/src/room_proto_converter.h b/src/room_proto_converter.h index 2189097c..b834d262 100644 --- a/src/room_proto_converter.h +++ b/src/room_proto_converter.h @@ -56,6 +56,7 @@ LIVEKIT_INTERNAL_API ConnectionStateChangedEvent fromProto(const proto::Connecti LIVEKIT_INTERNAL_API DisconnectedEvent fromProto(const proto::Disconnected& in); LIVEKIT_INTERNAL_API ReconnectingEvent fromProto(const proto::Reconnecting& in); LIVEKIT_INTERNAL_API ReconnectedEvent fromProto(const proto::Reconnected& in); +LIVEKIT_INTERNAL_API TokenRefreshedEvent fromProto(const proto::TokenRefreshed& in); LIVEKIT_INTERNAL_API RoomEosEvent fromProto(const proto::RoomEOS& in); LIVEKIT_INTERNAL_API DataStreamHeaderReceivedEvent fromProto(const proto::DataStreamHeaderReceived& in); diff --git a/src/tests/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..6d78cdb7 100644 --- a/src/tests/integration/test_room.cpp +++ b/src/tests/integration/test_room.cpp @@ -17,12 +17,20 @@ #include #include +#include +#include +#include +#include +#include #include +#include #include #include #include "../common/test_common.h" +using namespace std::chrono_literals; + namespace livekit::test { // Server-dependent tests - require LIVEKIT_URL and LIVEKIT_TOKEN_A env vars @@ -49,6 +57,8 @@ class RoomTest : public ::testing::Test { }; TEST_F(RoomTest, ConnectToServer) { + ASSERT_TRUE(server_available_) << "LIVEKIT_URL and LIVEKIT_TOKEN_A not set"; + Room room; RoomOptions options; @@ -61,6 +71,8 @@ TEST_F(RoomTest, ConnectToServer) { } TEST_F(RoomTest, ConnectWithInvalidToken) { + ASSERT_TRUE(server_available_) << "LIVEKIT_URL and LIVEKIT_TOKEN_A not set"; + Room room; RoomOptions options; @@ -76,6 +88,46 @@ TEST_F(RoomTest, ConnectWithInvalidUrl) { EXPECT_FALSE(connected) << "Should fail to connect to invalid URL"; } +TEST_F(RoomTest, ConnectWithLiteralTokenSource) { + ASSERT_TRUE(server_available_) << "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); + } +} + +TEST_F(RoomTest, ConnectWithCustomTokenSource) { + ASSERT_TRUE(server_available_) << "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_; + // Note: token_ is generated by livekit-cli prior to this test run and passed in via environment variables + 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"; +} + namespace { class DisconnectTrackingDelegate : public RoomDelegate { @@ -89,10 +141,67 @@ class DisconnectTrackingDelegate : public RoomDelegate { DisconnectReason last_reason = DisconnectReason::Unknown; }; +class TokenRefreshTrackingDelegate : public RoomDelegate { +public: + void onTokenRefreshed(Room&, const TokenRefreshedEvent& ev) override { + { + const std::scoped_lock lock(mutex_); + refreshed_token_ = ev.token; + } + refresh_count_.fetch_add(1, std::memory_order_relaxed); + cv_.notify_all(); + } + + bool waitForRefresh(std::chrono::milliseconds timeout) { + std::unique_lock lock(mutex_); + return cv_.wait_for(lock, timeout, [this]() { return refresh_count_.load(std::memory_order_relaxed) > 0; }); + } + + std::string refreshedToken() const { + const std::scoped_lock lock(mutex_); + return refreshed_token_; + } + + int refreshCount() const { return refresh_count_.load(std::memory_order_relaxed); } + +private: + mutable std::mutex mutex_; + std::condition_variable cv_; + std::atomic refresh_count_{0}; + std::string refreshed_token_; +}; + } // namespace +// livekit-server sends RefreshToken immediately after join, then every ~5 minutes. +// See pkg/service/roommanager.go (refreshToken on session start + tokenRefreshInterval). +TEST_F(RoomTest, ServerRefreshTokenFiresDelegate) { + ASSERT_TRUE(server_available_) << "LIVEKIT_URL and LIVEKIT_TOKEN_A not set"; + + Room room; + TokenRefreshTrackingDelegate delegate; + room.setDelegate(&delegate); + + RoomOptions options; + ASSERT_TRUE(room.connect(server_url_, token_, options)) << "connect failed"; + ASSERT_EQ(room.connectionState(), ConnectionState::Connected); + + ASSERT_TRUE(delegate.waitForRefresh(30s)) + << "onTokenRefreshed should fire after join (livekit-server pushes RefreshToken on connect)"; + + const std::string refreshed = delegate.refreshedToken(); + EXPECT_FALSE(refreshed.empty()); + EXPECT_EQ(std::count(refreshed.begin(), refreshed.end(), '.'), 2) + << "refreshed token should be a well-formed JWT (header.payload.signature)"; + EXPECT_NE(refreshed, token_) << "server-issued refresh JWT should differ from the join token"; + + room.disconnect(); +} + // Case: User calls disconnect() TEST_F(RoomTest, UserDisconnect) { + ASSERT_TRUE(server_available_) << "LIVEKIT_URL and LIVEKIT_TOKEN_A not set"; + Room room; DisconnectTrackingDelegate delegate; room.setDelegate(&delegate); @@ -115,6 +224,8 @@ TEST_F(RoomTest, UserDisconnect) { // Case: Room goes out of scope while still connected TEST_F(RoomTest, DestructorDisconnect) { + ASSERT_TRUE(server_available_) << "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..025f6ad9 --- /dev/null +++ b/src/tests/integration/test_token_source_endpoint.cpp @@ -0,0 +1,84 @@ +/* + * 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. +// +// LIVEKIT_CREATE_TOKEN_URL holds the full endpoint URL. In CI it is wired from +// the livekit/token-server-action `token-url` output (see tests.yml). +std::optional resolveCreateTokenUrl() { + if (const char* url = std::getenv("LIVEKIT_CREATE_TOKEN_URL"); url != nullptr && url[0] != '\0') { + return std::string(url); + } + return std::nullopt; +} + +} // namespace + +// End-to-end: requires a real token endpoint pointed at a running +// livekit-server. CI starts livekit/token-server-action with the local dev +// server's credentials and sets LIVEKIT_CREATE_TOKEN_URL; 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() << "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..80090e8f --- /dev/null +++ b/src/tests/token_source_tester.cpp @@ -0,0 +1,256 @@ +/* + * 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 { + endpoint_url = "http://127.0.0.1:3000/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..8e42cc29 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,82 @@ 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) { + // Test fixture initializes by default, do this to emulate lack of initialization + 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 diff --git a/vcpkg.json b/vcpkg.json index 5161febe..5590f055 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -12,14 +12,7 @@ "version>=": "5.29.5" }, "abseil", - "spdlog" - ], - "features": { - "examples": { - "description": "Build example applications", - "dependencies": [ - "nlohmann-json" - ] - } - } + "spdlog", + "nlohmann-json" + ] } \ No newline at end of file