diff --git a/.changeset/add_a_tokensource_crate_to_the_rust_sdks.md b/.changeset/add_a_tokensource_crate_to_the_rust_sdks.md new file mode 100644 index 000000000..e4593b92a --- /dev/null +++ b/.changeset/add_a_tokensource_crate_to_the_rust_sdks.md @@ -0,0 +1,5 @@ +--- +livekit-token-source: patch +--- + +Add a TokenSource crate to the Rust SDKs - #1274 (@MaxHeimbrock) diff --git a/.changeset/add_livekit_token_source_crate.md b/.changeset/add_livekit_token_source_crate.md new file mode 100644 index 000000000..d0347fc4e --- /dev/null +++ b/.changeset/add_livekit_token_source_crate.md @@ -0,0 +1,5 @@ +--- +livekit-token-source: minor +--- + +Add the `livekit-token-source` crate: token sources for procuring LiveKit credentials, mirroring the JS SDK's `TokenSource` — `literal`, `endpoint` (standard token endpoint format), and `development_token_server`, plus `TokenSourceFixed` / `TokenSourceConfigurable` traits for custom backends. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1b64e56e4..374d5248f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,4 +8,5 @@ /livekit-datatrack/ @ladvoc @1egoman /livekit-data-stream/ @ladvoc @1egoman /livekit-wakeword/ @pham-tuan-binh -/livekit-net/ @jhugman \ No newline at end of file +/livekit-net/ @jhugman +/livekit-token-source/ @MaxHeimbrock diff --git a/Cargo.lock b/Cargo.lock index 5da102a03..1642c887f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4103,6 +4103,18 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "livekit-token-source" +version = "0.1.0" +dependencies = [ + "async-trait", + "livekit-net", + "serde", + "serde_json", + "thiserror 2.0.19", + "tokio", +] + [[package]] name = "livekit-uniffi" version = "0.1.8" @@ -7466,6 +7478,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "token_source" +version = "0.1.0" +dependencies = [ + "async-trait", + "livekit-token-source", + "serde_json", + "tokio", +] + [[package]] name = "tokio" version = "1.53.1" diff --git a/Cargo.toml b/Cargo.toml index e7e4565a9..a89e7b76e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "livekit-ffi", "livekit-uniffi", "livekit-datatrack", + "livekit-token-source", "livekit-ffi-node-bindings", "livekit-net", "livekit-runtime", @@ -36,6 +37,7 @@ members = [ "examples/save_to_disk", "examples/screensharing", "examples/send_bytes", + "examples/token_source", "examples/webhooks", ] @@ -53,6 +55,7 @@ livekit = { version = "0.8.2", path = "livekit" } livekit-api = { version = "0.6.2", path = "livekit-api" } livekit-ffi = { version = "0.12.74", path = "livekit-ffi" } livekit-datatrack = { version = "0.1.13", path = "livekit-datatrack" } +livekit-token-source = { version = "0.1.0", path = "livekit-token-source" } livekit-common = { version = "0.1.1", path = "livekit-common" } livekit-data-stream = { version = "0.1.2", path = "livekit-data-stream" } livekit-net = { version = "0.1.2", path = "livekit-net" } diff --git a/examples/token_source/Cargo.toml b/examples/token_source/Cargo.toml new file mode 100644 index 000000000..48e5cf03b --- /dev/null +++ b/examples/token_source/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "token_source" +version = "0.1.0" +edition.workspace = true +publish = false + +[dependencies] +livekit-token-source = { workspace = true, features = ["rustls-tls-native-roots"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +async-trait = "0.1" +serde_json = { workspace = true } \ No newline at end of file diff --git a/examples/token_source/src/main.rs b/examples/token_source/src/main.rs new file mode 100644 index 000000000..5881386d2 --- /dev/null +++ b/examples/token_source/src/main.rs @@ -0,0 +1,79 @@ +use async_trait::async_trait; +use livekit_token_source::{ + TokenSourceConfigurable, TokenSourceFetchOptions, TokenSourceFixed, TokenSourceResponse, + TokenSourceResult, +}; + +/// An example for a custom token source that reads credentials from a JSON file, e.g. +/// `{"server_url": "wss://...", "participant_token": "..."}`. +struct FileTokenSource { + path: std::path::PathBuf, +} + +#[async_trait] +impl TokenSourceFixed for FileTokenSource { + async fn fetch(&self) -> TokenSourceResult { + let contents = std::fs::read_to_string(&self.path).map_err(serde_json::Error::io)?; + let response = serde_json::from_str(&contents)?; + Ok(response) + } +} + +#[tokio::main] +async fn main() { + // A literal token source returns a fixed set of pre-provisioned credentials. + let literal = + livekit_token_source::literal("wss://example.livekit.cloud", ""); + match literal.fetch().await { + Ok(response) => println!( + "literal: server_url={} participant_token={}", + response.server_url, response.participant_token + ), + Err(error) => eprintln!("literal fetch failed: {error}"), + } + + // A custom token source can procure credentials from anywhere; this one + // reads them from a JSON file next to this example's Cargo.toml. + let file_source = + FileTokenSource { path: concat!(env!("CARGO_MANIFEST_DIR"), "/token.txt").into() }; + match file_source.fetch().await { + Ok(response) => println!( + "file: server_url={} participant_token={}", + response.server_url, response.participant_token + ), + Err(error) => eprintln!("file fetch failed: {error}"), + } + + // The remaining sources query LiveKit's development token server, which + // requires the ID of a sandbox created in your LiveKit Cloud project. + let sandbox_id = "your sandbox id".to_string(); + + let options = TokenSourceFetchOptions::new() + .with_room_name("example-room") + .with_participant_identity("example-user"); + + // Development token server: for prototyping only, NOT for production use. + let development_token_server = + livekit_token_source::development_token_server(sandbox_id.clone()); + match development_token_server.fetch(&options).await { + Ok(response) => println!( + "development token server: server_url={} participant_token={}", + response.server_url, response.participant_token + ), + Err(error) => eprintln!("development token server fetch failed: {error}"), + } + + // Endpoint: POSTs the fetch options to a token endpoint using the standard + // format; here pointed at the same development token server. + let endpoint = livekit_token_source::endpoint( + "https://cloud-api.livekit.io/api/v2/sandbox/connection-details", + ) + .with_header("X-Sandbox-ID", sandbox_id); + match endpoint.fetch(&options).await { + Ok(response) => println!( + "endpoint: server_url={} participant_token={}", + response.server_url, response.participant_token + ), + Err(error) => eprintln!("endpoint fetch failed: {error}"), + } +} diff --git a/examples/token_source/token.txt b/examples/token_source/token.txt new file mode 100644 index 000000000..6f16c7520 --- /dev/null +++ b/examples/token_source/token.txt @@ -0,0 +1 @@ +{"server_url": "url from file", "participant_token": "token from file"} diff --git a/knope.toml b/knope.toml index 4e8783902..2855e2f69 100644 --- a/knope.toml +++ b/knope.toml @@ -180,4 +180,12 @@ versioned_files = [ "Cargo.lock", { path = "Cargo.toml", dependency = "livekit-runtime" }, ] -changelog = "livekit-runtime/CHANGELOG.md" \ No newline at end of file +changelog = "livekit-runtime/CHANGELOG.md" + +[packages.livekit-token-source] +versioned_files = [ + "livekit-token-source/Cargo.toml", + "Cargo.lock", + { path = "Cargo.toml", dependency = "livekit-token-source" }, +] +changelog = "livekit-token-source/CHANGELOG.md" diff --git a/livekit-token-source/Cargo.toml b/livekit-token-source/Cargo.toml new file mode 100644 index 000000000..f10499286 --- /dev/null +++ b/livekit-token-source/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "livekit-token-source" +description = "Token sources for the LiveKit Rust SDK" +version = "0.1.0" +license.workspace = true +edition.workspace = true +repository.workspace = true +readme = "README.md" + +[features] +# By default no TLS is enabled; pick one of the TLS features below. +default = ["native-tokio"] + +# Backend bundles — pass-throughs to livekit-net. With none enabled the crate is +# backend-blind: the host must register a client via `livekit_net::set_http_client`. +native-tokio = ["livekit-net/native-tokio"] +native-async = ["livekit-net/native-async"] +native-dispatcher = ["livekit-net/native-dispatcher"] + +# TLS pass-throughs (only meaningful with a native backend). See livekit-api's +# Cargo.toml for guidance on choosing one, notably in container deployments. +native-tls = ["livekit-net/native-tls"] +native-tls-vendored = ["livekit-net/native-tls-vendored"] +rustls-tls-native-roots = ["livekit-net/rustls-tls-native-roots"] +rustls-tls-webpki-roots = ["livekit-net/rustls-tls-webpki-roots"] + +[dependencies] +async-trait = "0.1" +livekit-net = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["rt", "macros"] } diff --git a/livekit-token-source/README.md b/livekit-token-source/README.md new file mode 100644 index 000000000..550527206 --- /dev/null +++ b/livekit-token-source/README.md @@ -0,0 +1,29 @@ +# LiveKit Token Source + +Token sources for the LiveKit Rust SDK. A token source procures the credentials — server URL and +participant token — needed to join a LiveKit room. + +Three sources ship with the crate, constructed via factory functions: + +- `literal` — a fixed set of pre-provisioned credentials. +- `endpoint` — fetches credentials from a token endpoint implementing the + [standard format](https://docs.livekit.io/frontends/build/authentication/endpoint/). +- `development_token_server` — queries a LiveKit + [development token server](https://docs.livekit.io/frontends/build/authentication/sandbox-token-server/) + for prototyping. **Not for production use.** + +Custom credential backends can implement the `TokenSourceFixed` or `TokenSourceConfigurable` +traits. + +```rust +use livekit_token_source::{TokenSourceConfigurable, TokenSourceFetchOptions}; + +let source = livekit_token_source::endpoint("https://example.com/api/token"); +let options = TokenSourceFetchOptions::new() + .with_room_name("my-room") + .with_participant_identity("user-123"); +let response = source.fetch(&options).await?; +// connect with response.server_url / response.participant_token +``` + +See [examples/token_source](../examples/token_source) for a runnable example. diff --git a/livekit-token-source/src/error.rs b/livekit-token-source/src/error.rs new file mode 100644 index 000000000..dd0230ff4 --- /dev/null +++ b/livekit-token-source/src/error.rs @@ -0,0 +1,29 @@ +// Copyright 2026 LiveKit, Inc. +// +// 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 under the License. + +/// Errors returned when procuring credentials from a token source. +#[derive(Debug, thiserror::Error)] +pub enum TokenSourceError { + #[error("no HTTP client available; enable a livekit-net backend feature or call livekit_net::set_http_client")] + TransportNotConfigured, + + #[error("failed to fetch token: {0}")] + Transport(#[from] livekit_net::TransportError), + + #[error("failed to serialize request / parse response: {0}")] + Json(#[from] serde_json::Error), + + #[error("token server returned {status}: {body}")] + Server { status: u16, body: String }, +} diff --git a/livekit-token-source/src/lib.rs b/livekit-token-source/src/lib.rs new file mode 100644 index 000000000..de72cb06b --- /dev/null +++ b/livekit-token-source/src/lib.rs @@ -0,0 +1,39 @@ +// Copyright 2026 LiveKit, Inc. +// +// 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 under the License. + +//! Token sources for the LiveKit Rust SDK. +//! +//! A token source procures the credentials — server URL and participant +//! token — needed to join a LiveKit room. Construct one via the +//! [`literal`] / [`endpoint`] / [`development_token_server`] factory +//! functions, or implement [`TokenSourceFixed`] / [`TokenSourceConfigurable`] +//! to plug in a custom credential backend. + +mod error; +mod request; +mod response; +mod token_source; + +pub use error::TokenSourceError; +pub use request::TokenSourceFetchOptions; +pub use response::TokenSourceResponse; +pub use response::TokenSourceResult; +pub use token_source::development_token_server; +pub use token_source::endpoint; +pub use token_source::literal; +pub use token_source::TokenSourceConfigurable; +pub use token_source::TokenSourceDevelopmentTokenServer; +pub use token_source::TokenSourceEndpoint; +pub use token_source::TokenSourceFixed; +pub use token_source::TokenSourceLiteral; diff --git a/livekit-token-source/src/request.rs b/livekit-token-source/src/request.rs new file mode 100644 index 000000000..ba637914f --- /dev/null +++ b/livekit-token-source/src/request.rs @@ -0,0 +1,174 @@ +// Copyright 2026 LiveKit, Inc. +// +// 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 under the License. + +use std::collections::HashMap; + +/// Per-call overrides used to parameterize a token request. +/// +/// Every option is optional: anything left unset is omitted from the request and the +/// server picks a default. Set only the options you care about: +/// +/// ``` +/// # use livekit_token_source::TokenSourceFetchOptions; +/// let options = TokenSourceFetchOptions::new() +/// .with_room_name("my-room") +/// .with_participant_identity("user-123"); +/// ``` +#[derive(Default, Clone, Debug)] +pub struct TokenSourceFetchOptions { + pub(crate) room_name: Option, + pub(crate) participant_name: Option, + pub(crate) participant_identity: Option, + pub(crate) participant_metadata: Option, + pub(crate) participant_attributes: Option>, + pub(crate) agent_name: Option, + pub(crate) agent_metadata: Option, + pub(crate) deployment: Option, +} + +impl TokenSourceFetchOptions { + /// Creates empty fetch options; the server picks a default for every field. + pub fn new() -> Self { + Self::default() + } + + /// Sets the name of the room being requested when generating credentials. + pub fn with_room_name(mut self, value: impl Into) -> Self { + self.room_name = Some(value.into()); + self + } + + /// Sets the name of the participant being requested when generating credentials. + pub fn with_participant_name(mut self, value: impl Into) -> Self { + self.participant_name = Some(value.into()); + self + } + + /// Sets the identity of the participant being requested when generating credentials. + pub fn with_participant_identity(mut self, value: impl Into) -> Self { + self.participant_identity = Some(value.into()); + self + } + + /// Sets the metadata of the participant being requested when generating credentials. + pub fn with_participant_metadata(mut self, value: impl Into) -> Self { + self.participant_metadata = Some(value.into()); + self + } + + /// Adds the given attributes to the participant attributes, keeping any set previously. + /// A key that was already set is overwritten with its new value. + pub fn with_participant_attributes( + mut self, + value: impl IntoIterator, impl Into)>, + ) -> Self { + self.participant_attributes + .get_or_insert_with(HashMap::new) + .extend(value.into_iter().map(|(k, v)| (k.into(), v.into()))); + self + } + + /// Adds a single attribute to the participant attributes, keeping any set previously. + pub fn with_participant_attribute( + mut self, + key: impl Into, + value: impl Into, + ) -> Self { + self.participant_attributes + .get_or_insert_with(HashMap::new) + .insert(key.into(), value.into()); + self + } + + /// Sets the name of the agent to dispatch into the room. + pub fn with_agent_name(mut self, value: impl Into) -> Self { + self.agent_name = Some(value.into()); + self + } + + /// Sets the metadata to pass to the dispatched agent. + pub fn with_agent_metadata(mut self, value: impl Into) -> Self { + self.agent_metadata = Some(value.into()); + self + } + + /// Sets the agent deployment to target. Leave unset to target the production deployment. + pub fn with_deployment(mut self, value: impl Into) -> Self { + self.deployment = Some(value.into()); + self + } +} + +/// The JSON body posted to the token endpoint. Built from [`TokenSourceFetchOptions`]; +/// the flat agent fields get nested under `room_config.agents` to match the server's schema. +#[derive(serde::Serialize)] +pub(crate) struct TokenSourceRequest { + #[serde(skip_serializing_if = "Option::is_none")] + room_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + participant_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + participant_identity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + participant_metadata: Option, + #[serde(skip_serializing_if = "Option::is_none")] + participant_attributes: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + room_config: Option, +} + +/// Non-exhaustive list of room config parameter, the full list is in livekit_room.proto +#[derive(serde::Serialize)] +struct RoomConfig { + agents: Vec, +} + +#[derive(serde::Serialize)] +struct AgentDispatch { + #[serde(skip_serializing_if = "Option::is_none")] + agent_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + metadata: Option, + #[serde(skip_serializing_if = "Option::is_none")] + deployment: Option, +} + +impl From<&TokenSourceFetchOptions> for TokenSourceRequest { + fn from(options: &TokenSourceFetchOptions) -> TokenSourceRequest { + // Only include a room_config when at least one agent field is set. + let room_config = if options.agent_name.is_some() + || options.agent_metadata.is_some() + || options.deployment.is_some() + { + Some(RoomConfig { + agents: vec![AgentDispatch { + agent_name: options.agent_name.clone(), + metadata: options.agent_metadata.clone(), + deployment: options.deployment.clone(), + }], + }) + } else { + None + }; + + TokenSourceRequest { + room_name: options.room_name.clone(), + participant_name: options.participant_name.clone(), + participant_identity: options.participant_identity.clone(), + participant_metadata: options.participant_metadata.clone(), + participant_attributes: options.participant_attributes.clone(), + room_config, + } + } +} diff --git a/livekit-token-source/src/response.rs b/livekit-token-source/src/response.rs new file mode 100644 index 000000000..c576f1c32 --- /dev/null +++ b/livekit-token-source/src/response.rs @@ -0,0 +1,26 @@ +// Copyright 2026 LiveKit, Inc. +// +// 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 under the License. + +use crate::error::TokenSourceError; + +/// The credentials returned by a token source: the server to connect to and +/// the participant token to authenticate with. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct TokenSourceResponse { + pub server_url: String, + pub participant_token: String, +} + +/// Result alias used by all token source operations. +pub type TokenSourceResult = Result; diff --git a/livekit-token-source/src/token_source.rs b/livekit-token-source/src/token_source.rs new file mode 100644 index 000000000..56293cb2d --- /dev/null +++ b/livekit-token-source/src/token_source.rs @@ -0,0 +1,173 @@ +// Copyright 2026 LiveKit, Inc. +// +// 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 under the License. + +use std::collections::HashMap; + +use crate::error::TokenSourceError; +use crate::request::{TokenSourceFetchOptions, TokenSourceRequest}; +use crate::response::{TokenSourceResponse, TokenSourceResult}; +use async_trait::async_trait; +use livekit_net::{Header, HttpClientExt}; + +const DEVELOPMENT_TOKEN_SERVER_ENDPOINT_URL: &str = + "https://cloud-api.livekit.io/api/v2/sandbox/connection-details"; +const DEVELOPMENT_TOKEN_SERVER_ID_HEADER: &str = "X-Sandbox-ID"; + +/// A token source whose credentials are not parameterized: `fetch` takes no +/// options and every call resolves the same way. +#[async_trait] +pub trait TokenSourceFixed { + async fn fetch(&self) -> TokenSourceResult; +} + +/// The return type of [`literal`]. +pub struct TokenSourceLiteral { + response: TokenSourceResponse, +} + +#[async_trait] +impl TokenSourceFixed for TokenSourceLiteral { + async fn fetch(&self) -> TokenSourceResult { + Ok(self.response.clone()) + } +} + +/// Creates a token source holding a single, literal set of credentials, +/// returned as-is on every fetch. +pub fn literal( + server_url: impl Into, + participant_token: impl Into, +) -> TokenSourceLiteral { + TokenSourceLiteral { + response: TokenSourceResponse { + server_url: server_url.into(), + participant_token: participant_token.into(), + }, + } +} + +/// A token source that generates credentials from per-call +/// [`TokenSourceFetchOptions`] (room name, participant identity, agent +/// dispatch, ...). +/// +/// Implement this trait to plug a custom credential backend into code that is +/// generic over token sources. +#[async_trait] +pub trait TokenSourceConfigurable { + async fn fetch( + &self, + options: &TokenSourceFetchOptions, + ) -> TokenSourceResult; +} + +/// The return type of [`endpoint`]. +pub struct TokenSourceEndpoint { + endpoint_url: String, + headers: HashMap, +} + +#[async_trait] +impl TokenSourceConfigurable for TokenSourceEndpoint { + async fn fetch( + &self, + options: &TokenSourceFetchOptions, + ) -> TokenSourceResult { + let request = TokenSourceRequest::from(options); + + let http_client = + livekit_net::http_client().ok_or(TokenSourceError::TransportNotConfigured)?; + + let body = serde_json::to_vec(&request)?; + let mut headers = + vec![Header { name: "Content-Type".into(), value: "application/json".into() }]; + headers.extend( + self.headers + .iter() + .map(|(name, value)| Header { name: name.clone(), value: value.clone() }), + ); + + let response = http_client.post(self.endpoint_url.clone(), headers, body).await?; + + if !(200..300).contains(&response.status) { + return Err(TokenSourceError::Server { + status: response.status, + body: String::from_utf8_lossy(&response.body).into_owned(), + }); + } + + let connection_details = serde_json::from_slice::(&response.body)?; + Ok(connection_details) + } +} + +/// Creates a token source that fetches credentials from the given URL +/// using the standard token endpoint format. +/// +/// The given headers are sent along with every request, e.g. for +/// authentication against the endpoint. +/// +/// See +/// for the endpoint contract. +pub fn endpoint(endpoint_url: impl Into) -> TokenSourceEndpoint { + TokenSourceEndpoint { endpoint_url: endpoint_url.into(), headers: HashMap::new() } +} + +impl TokenSourceEndpoint { + /// Adds a single header to the headers sent with every request, keeping any set previously. + pub fn with_header(mut self, key: impl Into, value: impl Into) -> Self { + self.headers.insert(key.into(), value.into()); + self + } + + /// Adds the given headers to the headers sent with every request, keeping any set previously. + /// A key that was already set is overwritten with its new value. + pub fn with_headers( + mut self, + value: impl IntoIterator, impl Into)>, + ) -> Self { + self.headers.extend(value.into_iter().map(|(k, v)| (k.into(), v.into()))); + self + } +} + +/// The return type of [`development_token_server`]. +pub struct TokenSourceDevelopmentTokenServer { + token_source_endpoint: TokenSourceEndpoint, +} + +#[async_trait] +impl TokenSourceConfigurable for TokenSourceDevelopmentTokenServer { + async fn fetch( + &self, + options: &TokenSourceFetchOptions, + ) -> TokenSourceResult { + self.token_source_endpoint.fetch(options).await + } +} + +/// Creates a token source that queries a LiveKit development token server +/// for credentials, for quick prototyping / getting-started use cases. +/// +/// **This token provider is INSECURE and should NOT be used in +/// production.** +/// +/// See . +pub fn development_token_server( + token_server_id: impl Into, +) -> TokenSourceDevelopmentTokenServer { + TokenSourceDevelopmentTokenServer { + token_source_endpoint: endpoint(DEVELOPMENT_TOKEN_SERVER_ENDPOINT_URL) + .with_header(DEVELOPMENT_TOKEN_SERVER_ID_HEADER, token_server_id), + } +} diff --git a/livekit-token-source/tests/mock_http.rs b/livekit-token-source/tests/mock_http.rs new file mode 100644 index 000000000..109446ce8 --- /dev/null +++ b/livekit-token-source/tests/mock_http.rs @@ -0,0 +1,177 @@ +// Copyright 2026 LiveKit, Inc. +// +// 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 under the License. + +//! Tests `TokenSourceEndpoint::fetch` against a mock `livekit_net::HttpClient`. +//! +//! The livekit-net registry is process-wide and first-set-wins, so every test +//! lives in this one file and registration goes through a `Once`. Each test uses +//! a distinct endpoint URL; the mock dispatches its response on the URL and +//! records each request under it, so concurrently running tests never collide. + +use livekit_net::{Header, HttpMethod, HttpResponse, TransportError}; +use livekit_token_source::{ + endpoint, TokenSourceConfigurable, TokenSourceError, TokenSourceFetchOptions, +}; +use std::collections::HashMap; +use std::sync::{Mutex, Once}; + +#[derive(Clone)] +struct Captured { + method: HttpMethod, + headers: Vec
, + body: Option>, +} + +static CAPTURED: Mutex>> = Mutex::new(None); + +fn captured(url: &str) -> Captured { + CAPTURED.lock().unwrap().as_ref().unwrap().get(url).expect("request not captured").clone() +} + +struct MockHttp; + +#[async_trait::async_trait] +impl livekit_net::HttpClient for MockHttp { + async fn request( + &self, + method: HttpMethod, + url: String, + headers: Vec
, + body: Option>, + ) -> Result { + CAPTURED + .lock() + .unwrap() + .get_or_insert_with(HashMap::new) + .insert(url.clone(), Captured { method, headers, body }); + + if url.contains("server-error") { + return Ok(HttpResponse { status: 500, headers: vec![], body: b"boom".to_vec() }); + } + if url.contains("badjson") { + return Ok(HttpResponse { + status: 200, + headers: vec![], + body: b"this is not json".to_vec(), + }); + } + if url.contains("connrefused") { + return Err(TransportError::Connection("connection refused".into())); + } + let body = br#"{"server_url":"wss://mock.livekit.cloud","participant_token":"tok-123"}"#; + Ok(HttpResponse { status: 200, headers: vec![], body: body.to_vec() }) + } +} + +static INSTALL: Once = Once::new(); + +fn install_mock() { + INSTALL.call_once(|| livekit_net::set_http_client(std::sync::Arc::new(MockHttp))); +} + +fn header<'a>(headers: &'a [Header], name: &str) -> Option<&'a str> { + headers.iter().find(|h| h.name.eq_ignore_ascii_case(name)).map(|h| h.value.as_str()) +} + +#[tokio::test] +async fn fetch_posts_json_and_parses_response() { + install_mock(); + let url = "https://token.test/ok"; + let endpoint = endpoint(url).with_headers([("X-Sandbox-ID", "sandbox-42")]); + let options = TokenSourceFetchOptions::new() + .with_room_name("my-room") + .with_participant_identity("user-123"); + + let response = endpoint.fetch(&options).await.expect("fetch should succeed"); + assert_eq!(response.server_url, "wss://mock.livekit.cloud"); + assert_eq!(response.participant_token, "tok-123"); + + let req = captured(url); + assert_eq!(req.method, HttpMethod::Post); + assert_eq!(header(&req.headers, "Content-Type"), Some("application/json")); + assert_eq!(header(&req.headers, "X-Sandbox-ID"), Some("sandbox-42")); + + let body: serde_json::Value = serde_json::from_slice(&req.body.expect("body")).unwrap(); + assert_eq!(body["room_name"], "my-room"); + assert_eq!(body["participant_identity"], "user-123"); + // Unset options must be omitted, not sent as null. + assert!(body.get("participant_name").is_none()); +} + +#[tokio::test] +async fn non_2xx_maps_to_server_error() { + install_mock(); + let endpoint = endpoint("https://token.test/server-error"); + + let err = endpoint.fetch(&TokenSourceFetchOptions::new()).await.unwrap_err(); + match err { + TokenSourceError::Server { status, body } => { + assert_eq!(status, 500); + assert_eq!(body, "boom"); + } + other => panic!("expected Server error, got {other:?}"), + } +} + +#[tokio::test] +async fn invalid_json_maps_to_json_error() { + install_mock(); + let endpoint = endpoint("https://token.test/badjson"); + + let err = endpoint.fetch(&TokenSourceFetchOptions::new()).await.unwrap_err(); + assert!(matches!(err, TokenSourceError::Json(_)), "expected Json error, got {err:?}"); +} + +#[tokio::test] +async fn agent_options_nest_under_room_config() { + install_mock(); + let url = "https://token.test/agent"; + let endpoint = endpoint(url); + let options = TokenSourceFetchOptions::new() + .with_agent_name("my-agent") + .with_agent_metadata("meta") + .with_deployment("staging"); + + endpoint.fetch(&options).await.expect("fetch should succeed"); + + let req = captured(url); + let body: serde_json::Value = serde_json::from_slice(&req.body.expect("body")).unwrap(); + let agent = &body["room_config"]["agents"][0]; + assert_eq!(agent["agent_name"], "my-agent"); + assert_eq!(agent["metadata"], "meta"); + assert_eq!(agent["deployment"], "staging"); +} + +#[tokio::test] +async fn room_config_is_omitted_without_agent_options() { + install_mock(); + let url = "https://token.test/no-agent"; + let endpoint = endpoint(url); + let options = TokenSourceFetchOptions::new().with_room_name("plain-room"); + + endpoint.fetch(&options).await.expect("fetch should succeed"); + + let req = captured(url); + let body: serde_json::Value = serde_json::from_slice(&req.body.expect("body")).unwrap(); + assert!(body.get("room_config").is_none()); +} + +#[tokio::test] +async fn transport_error_maps_to_transport_variant() { + install_mock(); + let endpoint = endpoint("https://token.test/connrefused"); + + let err = endpoint.fetch(&TokenSourceFetchOptions::new()).await.unwrap_err(); + assert!(matches!(err, TokenSourceError::Transport(_)), "expected Transport error, got {err:?}"); +}