diff --git a/.changes/token-source b/.changes/token-source new file mode 100644 index 00000000..3f532304 --- /dev/null +++ b/.changes/token-source @@ -0,0 +1 @@ +patch type="added" "Token source API with caching, endpoint helpers" diff --git a/lib/livekit_client.dart b/lib/livekit_client.dart index 72a0a31f..88f460c5 100644 --- a/lib/livekit_client.dart +++ b/lib/livekit_client.dart @@ -60,3 +60,11 @@ export 'src/types/video_encoding.dart'; export 'src/types/video_parameters.dart'; export 'src/widgets/screen_select_dialog.dart'; export 'src/widgets/video_track_renderer.dart'; +export 'src/token_source/token_source.dart'; +export 'src/token_source/room_configuration.dart'; +export 'src/token_source/literal.dart'; +export 'src/token_source/endpoint.dart'; +export 'src/token_source/custom.dart'; +export 'src/token_source/caching.dart'; +export 'src/token_source/sandbox.dart'; +export 'src/token_source/jwt.dart'; diff --git a/lib/src/token_source/caching.dart b/lib/src/token_source/caching.dart new file mode 100644 index 00000000..f11d9fa0 --- /dev/null +++ b/lib/src/token_source/caching.dart @@ -0,0 +1,176 @@ +// Copyright 2024 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. + +import 'dart:async'; + +import '../support/reusable_completer.dart'; +import 'jwt.dart'; +import 'token_source.dart'; + +/// A validator function that determines if cached credentials are still valid. +/// +/// The validator receives the original request options and cached response, and should +/// return `true` if the cached credentials are still valid for the given request. +/// +/// The default validator checks JWT expiration using [isResponseExpired]. +typedef TokenValidator = bool Function(TokenRequestOptions options, TokenSourceResponse response); + +/// A tuple containing the request options and response that were cached. +class TokenStoreItem { + final TokenRequestOptions options; + final TokenSourceResponse response; + + const TokenStoreItem({ + required this.options, + required this.response, + }); +} + +/// Protocol for storing and retrieving cached token credentials. +/// +/// Implement this abstract class to create custom storage solutions like +/// SharedPreferences or secure storage for token caching. +abstract class TokenStore { + /// Store credentials in the store. + /// + /// This replaces any existing cached credentials with the new ones. + Future store(TokenRequestOptions options, TokenSourceResponse response); + + /// Retrieve the cached credentials. + /// + /// Returns the cached credentials if found, null otherwise. + Future retrieve(); + + /// Clear all stored credentials. + Future clear(); +} + +/// A simple in-memory store implementation for token caching. +/// +/// This store keeps credentials in memory and is lost when the app is terminated. +/// Suitable for development and testing. +class InMemoryTokenStore implements TokenStore { + TokenStoreItem? _cached; + + @override + Future store(TokenRequestOptions options, TokenSourceResponse response) async { + _cached = TokenStoreItem(options: options, response: response); + } + + @override + Future retrieve() async { + return _cached; + } + + @override + Future clear() async { + _cached = null; + } +} + +/// Default validator that checks JWT expiration using [isResponseExpired]. +bool _defaultValidator(TokenRequestOptions options, TokenSourceResponse response) { + return !isResponseExpired(response); +} + +/// A token source that caches credentials from any [TokenSourceConfigurable] using a configurable store. +/// +/// This wrapper improves performance by avoiding redundant token requests when credentials are still valid. +/// It automatically validates cached tokens and fetches new ones when needed. +/// +/// The cache will refetch credentials when: +/// - The cached token has expired (validated via [TokenValidator]) +/// - The request options have changed +/// - The cache has been explicitly invalidated via [invalidate] +class CachingTokenSource implements TokenSourceConfigurable { + final TokenSourceConfigurable _wrapped; + final TokenStore _store; + final TokenValidator _validator; + final Map> _inflightRequests = {}; + + /// Initialize a caching wrapper around any token source. + /// + /// - Parameters: + /// - wrapped: The underlying token source to wrap and cache + /// - store: The store implementation to use for caching (defaults to in-memory store) + /// - validator: A function to determine if cached credentials are still valid (defaults to JWT expiration check) + CachingTokenSource( + this._wrapped, { + TokenStore? store, + TokenValidator? validator, + }) : _store = store ?? InMemoryTokenStore(), + _validator = validator ?? _defaultValidator; + + @override + Future fetch(TokenRequestOptions options) async { + final existingCompleter = _inflightRequests[options]; + if (existingCompleter != null && existingCompleter.isActive) { + return existingCompleter.future; + } + + final completer = existingCompleter ?? ReusableCompleter(); + _inflightRequests[options] = completer; + final resultFuture = completer.future; + + try { + final cached = await _store.retrieve(); + if (cached != null && cached.options == options && _validator(cached.options, cached.response)) { + completer.complete(cached.response); + return resultFuture; + } + + final response = await _wrapped.fetch(options); + await _store.store(options, response); + completer.complete(response); + return resultFuture; + } catch (e, stackTrace) { + completer.completeError(e, stackTrace); + rethrow; + } finally { + _inflightRequests.remove(options); + } + } + + /// Invalidate the cached credentials, forcing a fresh fetch on the next request. + Future invalidate() async { + await _store.clear(); + } + + /// Get the cached credentials if one exists. + Future cachedResponse() async { + final cached = await _store.retrieve(); + return cached?.response; + } +} + +/// Extension to add caching capabilities to any [TokenSourceConfigurable]. +extension CachedTokenSource on TokenSourceConfigurable { + /// Wraps this token source with caching capabilities. + /// + /// The returned token source will reuse valid tokens and only fetch new ones when needed. + /// + /// - Parameters: + /// - store: The store implementation to use for caching (defaults to in-memory store) + /// - validator: A function to determine if cached credentials are still valid (defaults to JWT expiration check) + /// - Returns: A caching token source that wraps this token source + CachingTokenSource cached({ + TokenStore? store, + TokenValidator? validator, + }) => + CachingTokenSource( + this, + store: store, + validator: validator, + ); +} diff --git a/lib/src/token_source/custom.dart b/lib/src/token_source/custom.dart new file mode 100644 index 00000000..424fc2ed --- /dev/null +++ b/lib/src/token_source/custom.dart @@ -0,0 +1,35 @@ +// Copyright 2024 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. + +import 'token_source.dart'; + +/// Function signature for custom token generation logic. +typedef CustomTokenFunction = Future Function(TokenRequestOptions options); + +/// A custom token source that executes provided logic to fetch credentials. +/// +/// This allows you to implement your own token fetching strategy with full control +/// over how credentials are generated or retrieved. +class CustomTokenSource implements TokenSourceConfigurable { + final CustomTokenFunction _function; + + /// Initialize with a custom token generation function. + /// + /// The [function] will be called whenever credentials need to be fetched, + /// receiving [TokenRequestOptions] and returning a [TokenSourceResponse]. + CustomTokenSource(CustomTokenFunction function) : _function = function; + + @override + Future fetch(TokenRequestOptions options) async => _function(options); +} diff --git a/lib/src/token_source/endpoint.dart b/lib/src/token_source/endpoint.dart new file mode 100644 index 00000000..c5210380 --- /dev/null +++ b/lib/src/token_source/endpoint.dart @@ -0,0 +1,114 @@ +// Copyright 2024 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. + +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'token_source.dart'; + +/// Error thrown when the token server responds with a non-success HTTP status code. +class TokenSourceHttpException implements Exception { + /// The endpoint that returned the error. + final Uri uri; + + /// The HTTP status code returned by the endpoint. + final int statusCode; + + /// The raw response body returned by the endpoint. + final String body; + + const TokenSourceHttpException({ + required this.uri, + required this.statusCode, + required this.body, + }); + + @override + String toString() { + return 'TokenSourceHttpException(statusCode: $statusCode, uri: $uri, body: $body)'; + } +} + +/// A token source that fetches credentials via HTTP requests from a custom backend. +/// +/// This implementation: +/// - Sends a POST request to the specified URL (configurable via [method]) +/// - Encodes the request parameters as [TokenRequestOptions] JSON in the request body +/// - Includes any custom headers specified via [headers] +/// - Expects the response to be decoded as [TokenSourceResponse] JSON +/// - Validates HTTP status codes (200-299) and throws appropriate errors for failures +class EndpointTokenSource implements TokenSourceConfigurable { + /// The URL endpoint for token generation. + /// This should point to your backend service that generates LiveKit tokens. + final Uri uri; + + /// The HTTP method to use for the token request (defaults to "POST"). + final String method; + + /// Additional HTTP headers to include with the request. + final Map headers; + + /// Optional HTTP client for testing purposes. + final http.Client? client; + + /// Initialize with endpoint configuration. + /// + /// - [url]: The URL endpoint for token generation + /// - [method]: The HTTP method (defaults to "POST") + /// - [headers]: Additional HTTP headers (optional) + /// - [client]: Custom HTTP client for testing (optional) + EndpointTokenSource({ + required Uri url, + this.method = 'POST', + this.headers = const {}, + this.client, + }) : uri = url; + + @override + Future fetch(TokenRequestOptions options) async { + final requestBody = jsonEncode(options.toRequest().toJson()); + final requestHeaders = { + 'Content-Type': 'application/json', + ...headers, + }; + + final httpClient = client ?? http.Client(); + final shouldCloseClient = client == null; + late final http.Response response; + + try { + final request = http.Request(method, uri); + request.headers.addAll(requestHeaders); + request.body = requestBody; + final streamedResponse = await httpClient.send(request); + response = await http.Response.fromStream(streamedResponse); + } finally { + if (shouldCloseClient) { + httpClient.close(); + } + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw TokenSourceHttpException( + uri: uri, + statusCode: response.statusCode, + body: response.body, + ); + } + + final responseBody = jsonDecode(response.body) as Map; + return TokenSourceResponse.fromJson(responseBody); + } +} diff --git a/lib/src/token_source/jwt.dart b/lib/src/token_source/jwt.dart new file mode 100644 index 00000000..d613ee7f --- /dev/null +++ b/lib/src/token_source/jwt.dart @@ -0,0 +1,240 @@ +// Copyright 2024 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. + +import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; + +import 'token_source.dart'; + +/// Parsed payload for a LiveKit-issued JWT. +class LiveKitJwtPayload { + LiveKitJwtPayload._(this._claims); + + factory LiveKitJwtPayload.fromClaims(Map claims) { + return LiveKitJwtPayload._(Map.from(claims)); + } + + static LiveKitJwtPayload? fromToken(String token) { + try { + final jwt = JWT.decode(token); + final claims = jwt.payload; + if (claims is Map) { + return LiveKitJwtPayload.fromClaims(claims); + } + if (claims is Map) { + return LiveKitJwtPayload.fromClaims(Map.from(claims)); + } + } on JWTException { + return null; + } + return null; + } + + final Map _claims; + + /// A readonly view of the raw JWT claims. + Map get claims => Map.unmodifiable(_claims); + + /// JWT issuer claim. + String? get issuer => _claims['iss'] as String?; + + /// JWT subject claim (participant identity). + String? get identity { + final sub = _claims['sub'] ?? _claims['identity']; + return sub is String ? sub : null; + } + + /// Display name for the participant. + String? get name => _claims['name'] as String?; + + /// Custom metadata associated with the participant. + String? get metadata => _claims['metadata'] as String?; + + /// Custom participant attributes. + Map? get attributes => _stringMapFor('attributes'); + + /// Video-specific grants embedded in the token, if present. + LiveKitVideoGrant? get video { + final raw = _claims['video']; + if (raw is Map) { + return LiveKitVideoGrant.fromJson(Map.from(raw)); + } + return null; + } + + /// Token expiration instant in UTC. + DateTime? get expiresAt => _dateTimeFor('exp'); + + /// Token not-before instant in UTC. + DateTime? get notBefore => _dateTimeFor('nbf'); + + /// Token issued-at instant in UTC. + DateTime? get issuedAt => _dateTimeFor('iat'); + + DateTime? _dateTimeFor(String key) { + final value = _claims[key]; + if (value is int) { + return DateTime.fromMillisecondsSinceEpoch(value * 1000, isUtc: true); + } + if (value is num) { + return DateTime.fromMillisecondsSinceEpoch((value * 1000).round(), isUtc: true); + } + if (value is String) { + final parsed = int.tryParse(value); + if (parsed != null) { + return DateTime.fromMillisecondsSinceEpoch(parsed * 1000, isUtc: true); + } + } + return null; + } + + Map? _stringMapFor(String key) { + final value = _claims[key]; + if (value is Map) { + final result = {}; + value.forEach((dynamic k, dynamic v) { + if (k != null && v != null) { + result[k.toString()] = v.toString(); + } + }); + return result; + } + return null; + } +} + +/// LiveKit-specific video grants embedded within a JWT. +class LiveKitVideoGrant { + final String? room; + final bool? roomCreate; + final bool? roomJoin; + final bool? roomList; + final bool? roomRecord; + final bool? roomAdmin; + final bool? canPublish; + final bool? canSubscribe; + final bool? canPublishData; + final List? canPublishSources; + final bool? hidden; + final bool? recorder; + + const LiveKitVideoGrant({ + this.room, + this.roomCreate, + this.roomJoin, + this.roomList, + this.roomRecord, + this.roomAdmin, + this.canPublish, + this.canSubscribe, + this.canPublishData, + this.canPublishSources, + this.hidden, + this.recorder, + }); + + factory LiveKitVideoGrant.fromJson(Map json) => LiveKitVideoGrant( + room: json['room'] as String?, + roomCreate: json['room_create'] as bool?, + roomJoin: json['room_join'] as bool?, + roomList: json['room_list'] as bool?, + roomRecord: json['room_record'] as bool?, + roomAdmin: json['room_admin'] as bool?, + canPublish: json['can_publish'] as bool?, + canSubscribe: json['can_subscribe'] as bool?, + canPublishData: json['can_publish_data'] as bool?, + canPublishSources: (json['can_publish_sources'] as List?)?.map((dynamic item) => item.toString()).toList(), + hidden: json['hidden'] as bool?, + recorder: json['recorder'] as bool?, + ); +} + +extension TokenSourceJwt on TokenSourceResponse { + /// Decode the participant token and return the parsed payload, if valid. + LiveKitJwtPayload? get jwtPayload => LiveKitJwtPayload.fromToken(participantToken); + + /// Returns `true` when the participant token is valid (not expired and past its not-before time). + /// + /// [tolerance] allows treating tokens as expired ahead of their actual expiry to avoid edge cases. + /// [currentTime] is primarily intended for testing; it defaults to the current system time. + bool hasValidToken({Duration tolerance = const Duration(seconds: 60), DateTime? currentTime}) { + final payload = jwtPayload; + if (payload == null) { + return false; + } + + final nowUtc = (currentTime ?? DateTime.timestamp()).toUtc(); + + final notBefore = payload.notBefore; + if (notBefore != null && nowUtc.isBefore(notBefore)) { + return false; + } + + final expiresAt = payload.expiresAt; + if (expiresAt == null) { + return false; + } + + final comparisonInstant = nowUtc.add(tolerance); + if (!expiresAt.isAfter(comparisonInstant)) { + return false; + } + + return true; + } +} + +/// Extension to extract LiveKit-specific claims from JWT tokens. +extension LiveKitClaims on JWT { + LiveKitJwtPayload? get _liveKitPayload { + final claims = payload; + if (claims is Map) { + return LiveKitJwtPayload.fromClaims(claims); + } + if (claims is Map) { + return LiveKitJwtPayload.fromClaims(Map.from(claims)); + } + return null; + } + + /// The display name for the participant. + String? get name => _liveKitPayload?.name; + + /// Custom metadata associated with the participant. + String? get metadata => _liveKitPayload?.metadata; + + /// Custom attributes for the participant. + Map? get attributes => _liveKitPayload?.attributes; + + /// Video-specific grants embedded in the token. + LiveKitVideoGrant? get video => _liveKitPayload?.video; +} + +/// Validates whether the JWT token in the response is expired or invalid. +/// +/// Returns `true` if the token is expired, invalid, or not yet valid (before nbf). +/// Returns `false` if the token is valid and can be used. +/// +/// This function checks: +/// - Token validity (can be decoded) +/// - Not-before time (nbf) - token is not yet valid +/// - Expiration time (exp) with configurable tolerance +/// +/// A missing expiration field is treated as invalid. +bool isResponseExpired( + TokenSourceResponse response, { + Duration tolerance = const Duration(seconds: 60), + DateTime? currentTime, +}) { + return !response.hasValidToken(tolerance: tolerance, currentTime: currentTime); +} diff --git a/lib/src/token_source/literal.dart b/lib/src/token_source/literal.dart new file mode 100644 index 00000000..61e8c04b --- /dev/null +++ b/lib/src/token_source/literal.dart @@ -0,0 +1,60 @@ +// Copyright 2024 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. + +import 'token_source.dart'; + +/// A token source that provides a fixed set of credentials without dynamic fetching. +/// +/// This is useful for testing, development, or when you have pre-generated tokens +/// that don't need to be refreshed dynamically. +/// +/// For dynamic token fetching, use [EndpointTokenSource] or implement [TokenSourceConfigurable]. +class LiteralTokenSource implements TokenSourceFixed { + /// The LiveKit server URL to connect to. + final String serverUrl; + + /// The JWT token for participant authentication. + final String participantToken; + + /// The display name for the participant (optional). + final String? participantName; + + /// The name of the room to join (optional). + final String? roomName; + + /// Initialize with fixed credentials. + /// + /// - Parameters: + /// - serverUrl: The LiveKit server URL to connect to + /// - participantToken: The JWT token for participant authentication + /// - participantName: The display name for the participant (optional) + /// - roomName: The name of the room to join (optional) + LiteralTokenSource({ + required this.serverUrl, + required this.participantToken, + this.participantName, + this.roomName, + }); + + /// Returns the fixed credentials without any network requests. + @override + Future fetch() async { + return TokenSourceResponse( + serverUrl: serverUrl, + participantToken: participantToken, + participantName: participantName, + roomName: roomName, + ); + } +} diff --git a/lib/src/token_source/room_configuration.dart b/lib/src/token_source/room_configuration.dart new file mode 100644 index 00000000..da019fcb --- /dev/null +++ b/lib/src/token_source/room_configuration.dart @@ -0,0 +1,90 @@ +// Copyright 2024 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. + +/// Configuration for dispatching an agent to a room. +class RoomAgentDispatch { + /// Name of the agent to dispatch. + final String? agentName; + + /// Metadata for the agent. + final String? metadata; + + const RoomAgentDispatch({ + this.agentName, + this.metadata, + }); + + Map toJson() => { + if (agentName != null) 'agent_name': agentName, + if (metadata != null) 'metadata': metadata, + }; +} + +/// Configuration for a LiveKit room. +/// +/// This class contains various settings that control room behavior such as timeouts, +/// participant limits, and agent dispatching. +class RoomConfiguration { + /// Room name, used as ID, must be unique. + final String? name; + + /// Number of seconds to keep the room open if no one joins. + final int? emptyTimeout; + + /// Number of seconds to keep the room open after everyone leaves. + final int? departureTimeout; + + /// Limit number of participants that can be in a room, excluding Egress and Ingress participants. + final int? maxParticipants; + + /// Metadata of room. + final String? metadata; + + /// Minimum playout delay of subscriber. + final int? minPlayoutDelay; + + /// Maximum playout delay of subscriber. + final int? maxPlayoutDelay; + + /// Improves A/V sync when playout delay set to a value larger than 200ms. + /// It will disable transceiver re-use so not recommended for rooms with frequent subscription changes. + final bool? syncStreams; + + /// Define agents that should be dispatched to this room. + final List? agents; + + const RoomConfiguration({ + this.name, + this.emptyTimeout, + this.departureTimeout, + this.maxParticipants, + this.metadata, + this.minPlayoutDelay, + this.maxPlayoutDelay, + this.syncStreams, + this.agents, + }); + + Map toJson() => { + if (name != null) 'name': name, + if (emptyTimeout != null) 'empty_timeout': emptyTimeout, + if (departureTimeout != null) 'departure_timeout': departureTimeout, + if (maxParticipants != null) 'max_participants': maxParticipants, + if (metadata != null) 'metadata': metadata, + if (minPlayoutDelay != null) 'min_playout_delay': minPlayoutDelay, + if (maxPlayoutDelay != null) 'max_playout_delay': maxPlayoutDelay, + if (syncStreams != null) 'sync_streams': syncStreams, + if (agents != null) 'agents': agents!.map((a) => a.toJson()).toList(), + }; +} diff --git a/lib/src/token_source/sandbox.dart b/lib/src/token_source/sandbox.dart new file mode 100644 index 00000000..2d582167 --- /dev/null +++ b/lib/src/token_source/sandbox.dart @@ -0,0 +1,44 @@ +// Copyright 2024 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. + +import 'endpoint.dart'; + +/// A token source that queries LiveKit's sandbox token server for development and testing. +/// +/// This token source connects to LiveKit Cloud's sandbox environment, which is perfect for +/// quick prototyping and getting started with LiveKit development. +/// +/// **Warning:** This token source is **insecure** and should **never** be used in production. +/// +/// For production use, implement [EndpointTokenSource] with your own backend or use [CustomTokenSource]. +class SandboxTokenSource extends EndpointTokenSource { + /// Initialize with a sandbox ID from LiveKit Cloud. + /// + /// The [sandboxId] is obtained from your LiveKit Cloud project's sandbox settings. + SandboxTokenSource({ + required String sandboxId, + }) : super( + url: Uri.parse('https://cloud-api.livekit.io/api/v2/sandbox/connection-details'), + headers: { + 'X-Sandbox-ID': _sanitizeSandboxId(sandboxId), + }, + ); +} + +String _sanitizeSandboxId(String sandboxId) { + var sanitized = sandboxId; + sanitized = sanitized.replaceFirst(RegExp(r'^[^a-zA-Z0-9]+'), ''); + sanitized = sanitized.replaceFirst(RegExp(r'[^a-zA-Z0-9]+$'), ''); + return sanitized; +} diff --git a/lib/src/token_source/token_source.dart b/lib/src/token_source/token_source.dart new file mode 100644 index 00000000..46137d53 --- /dev/null +++ b/lib/src/token_source/token_source.dart @@ -0,0 +1,179 @@ +// Copyright 2024 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. + +import 'package:collection/collection.dart'; + +import 'room_configuration.dart'; + +/// Request parameters for generating connection credentials. +class TokenRequestOptions { + /// The name of the room to connect to. Required for most token generation scenarios. + final String? roomName; + + /// The display name for the participant in the room. Optional but recommended for user experience. + final String? participantName; + + /// A unique identifier for the participant. Used for permissions and room management. + final String? participantIdentity; + + /// Custom metadata associated with the participant. Can be used for user profiles or additional context. + final String? participantMetadata; + + /// Custom attributes for the participant. Useful for storing key-value data like user roles or preferences. + final Map? participantAttributes; + + /// Name of the agent to dispatch. + final String? agentName; + + /// Metadata passed to the agent job. + final String? agentMetadata; + + const TokenRequestOptions({ + this.roomName, + this.participantName, + this.participantIdentity, + this.participantMetadata, + this.participantAttributes, + this.agentName, + this.agentMetadata, + }); + + /// Converts this options object to a wire-format request. + TokenSourceRequest toRequest() { + final List? agents = (agentName != null || agentMetadata != null) + ? [RoomAgentDispatch(agentName: agentName, metadata: agentMetadata)] + : null; + + return TokenSourceRequest( + roomName: roomName, + participantName: participantName, + participantIdentity: participantIdentity, + participantMetadata: participantMetadata, + participantAttributes: participantAttributes, + roomConfiguration: RoomConfiguration(agents: agents), + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! TokenRequestOptions) return false; + + return other.roomName == roomName && + other.participantName == participantName && + other.participantIdentity == participantIdentity && + other.participantMetadata == participantMetadata && + other.agentName == agentName && + other.agentMetadata == agentMetadata && + const MapEquality().equals(other.participantAttributes, participantAttributes); + } + + @override + int get hashCode { + return Object.hash( + roomName, + participantName, + participantIdentity, + participantMetadata, + agentName, + agentMetadata, + const MapEquality().hash(participantAttributes), + ); + } +} + +/// The JSON serializable format of the request sent to standard LiveKit token servers. +/// +/// This is an internal wire format class that separates the public API ([TokenRequestOptions]) +/// from the JSON structure sent over the network. +class TokenSourceRequest { + final String? roomName; + final String? participantName; + final String? participantIdentity; + final String? participantMetadata; + final Map? participantAttributes; + final RoomConfiguration? roomConfiguration; + + const TokenSourceRequest({ + this.roomName, + this.participantName, + this.participantIdentity, + this.participantMetadata, + this.participantAttributes, + this.roomConfiguration, + }); + + Map toJson() { + return { + if (roomName != null) 'room_name': roomName, + if (participantName != null) 'participant_name': participantName, + if (participantIdentity != null) 'participant_identity': participantIdentity, + if (participantMetadata != null) 'participant_metadata': participantMetadata, + if (participantAttributes != null) 'participant_attributes': participantAttributes, + if (roomConfiguration != null) 'room_config': roomConfiguration!.toJson(), + }; + } +} + +/// Response containing the credentials needed to connect to a LiveKit room. +class TokenSourceResponse { + /// The WebSocket URL for the LiveKit server. Use this to establish the connection. + final String serverUrl; + + /// The JWT token containing participant permissions and metadata. Required for authentication. + final String participantToken; + + /// The display name for the participant in the room. May be null if not specified. + final String? participantName; + + /// The name of the room the participant will join. May be null if not specified. + final String? roomName; + + const TokenSourceResponse({ + required this.serverUrl, + required this.participantToken, + this.participantName, + this.roomName, + }); + + factory TokenSourceResponse.fromJson(Map json) { + return TokenSourceResponse( + serverUrl: (json['server_url'] ?? json['serverUrl']) as String, + participantToken: (json['participant_token'] ?? json['participantToken']) as String, + participantName: (json['participant_name'] ?? json['participantName']) as String?, + roomName: (json['room_name'] ?? json['roomName']) as String?, + ); + } +} + +/// A token source that returns a fixed set of credentials without configurable options. +/// +/// This abstract class is designed for backwards compatibility with existing authentication infrastructure +/// that doesn't support dynamic room, participant, or agent parameter configuration. +abstract class TokenSourceFixed { + Future fetch(); +} + +/// A token source that provides configurable options for room, participant, and agent parameters. +/// +/// This abstract class allows dynamic configuration of connection parameters, making it suitable for +/// production applications that need flexible authentication and room management. +/// +/// Common implementations: +/// - [SandboxTokenSource]: For testing with LiveKit Cloud sandbox token server +/// - [EndpointTokenSource]: For custom backend endpoints using LiveKit's JSON format +/// - [CachingTokenSource]: For caching credentials (or use the `.cached()` extension method) +abstract class TokenSourceConfigurable { + Future fetch(TokenRequestOptions options); +} diff --git a/pubspec.lock b/pubspec.lock index ba94b337..b38eb7b1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: dd3d2ad434b9510001d089e8de7556d50c834481b9abc2891a0184a8493a19dc + sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d url: "https://pub.dev" source: hosted - version: "89.0.0" + version: "91.0.0" + adaptive_number: + dependency: transitive + description: + name: adaptive_number + sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77" + url: "https://pub.dev" + source: hosted + version: "1.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: c22b6e7726d1f9e5db58c7251606076a71ca0dbcf76116675edfadbec0c9e875 + sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 url: "https://pub.dev" source: hosted - version: "8.2.0" + version: "8.4.1" args: dependency: transitive description: @@ -45,10 +53,10 @@ packages: dependency: transitive description: name: build - sha256: "5b887c55a0f734b433b3b2d89f9cd1f99eb636b17e268a5b4259258bc916504b" + sha256: dfb67ccc9a78c642193e0c2d94cb9e48c2c818b3178a86097d644acdcde6a8d9 url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.0.2" built_collection: dependency: transitive description: @@ -129,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.6" + dart_jsonwebtoken: + dependency: "direct main" + description: + name: dart_jsonwebtoken + sha256: "0de65691c1d736e9459f22f654ddd6fd8368a271d4e41aa07e53e6301eff5075" + url: "https://pub.dev" + source: hosted + version: "3.3.1" dart_style: dependency: transitive description: @@ -169,6 +185,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.3" + ed25519_edwards: + dependency: transitive + description: + name: ed25519_edwards + sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd" + url: "https://pub.dev" + source: hosted + version: "0.3.1" fake_async: dependency: transitive description: @@ -300,10 +324,10 @@ packages: dependency: transitive description: name: logger - sha256: "55d6c23a6c15db14920e037fe7e0dc32e7cdaf3b64b4b25df2d541b5b6b81c0c" + sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 url: "https://pub.dev" source: hosted - version: "2.6.1" + version: "2.6.2" logging: dependency: "direct main" description: @@ -388,18 +412,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db" + sha256: e122c5ea805bb6773bb12ce667611265980940145be920cd09a4b0ec0285cb16 url: "https://pub.dev" source: hosted - version: "2.2.18" + version: "2.2.20" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" + sha256: efaec349ddfc181528345c56f8eda9d6cccd71c177511b132c6a0ddaefaa2738 url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.3" path_provider_linux: dependency: transitive description: @@ -448,6 +472,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" protobuf: dependency: "direct main" description: @@ -481,10 +513,10 @@ packages: dependency: transitive description: name: source_gen - sha256: ccf30b0c9fbcd79d8b6f5bfac23199fb354938436f62475e14aea0f29ee0f800 + sha256: "9098ab86015c4f1d8af6486b547b11100e73b193e1899015033cb3e14ad20243" url: "https://pub.dev" source: hosted - version: "4.0.1" + version: "4.0.2" source_span: dependency: transitive description: @@ -593,10 +625,10 @@ packages: dependency: transitive description: name: watcher - sha256: "5bf046f41320ac97a469d506261797f35254fa61c641741ef32dacda98b7d39c" + sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a" url: "https://pub.dev" source: hosted - version: "1.1.3" + version: "1.1.4" web: dependency: "direct main" description: @@ -617,10 +649,10 @@ packages: dependency: transitive description: name: win32 - sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e url: "https://pub.dev" source: hosted - version: "5.14.0" + version: "5.15.0" win32_registry: dependency: transitive description: @@ -655,4 +687,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.9.0 <4.0.0" - flutter: ">=3.29.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 17f5b97c..c55990b8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,7 @@ dependencies: web: ^1.0.0 mime_type: ^1.0.1 path: ^1.9.1 + dart_jsonwebtoken: ^3.3.1 # Fix version to avoid version conflicts between WebRTC-SDK pods, which both this package and flutter_webrtc depend on. flutter_webrtc: 1.2.0 diff --git a/test/token/caching_token_source_test.dart b/test/token/caching_token_source_test.dart new file mode 100644 index 00000000..06469b91 --- /dev/null +++ b/test/token/caching_token_source_test.dart @@ -0,0 +1,365 @@ +// Copyright 2024 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. + +import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livekit_client/src/token_source/caching.dart'; +import 'package:livekit_client/src/token_source/token_source.dart'; + +void main() { + group('CachingTokenSource', () { + test('caches valid token and reuses it', () async { + var fetchCount = 0; + final mockSource = _MockTokenSource((options) async { + fetchCount++; + final token = _generateValidToken(); + return TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + }); + + final cachingSource = CachingTokenSource(mockSource); + + // First fetch + final result1 = await cachingSource.fetch(const TokenRequestOptions()); + expect(fetchCount, 1); + + // Second fetch should use cache + final result2 = await cachingSource.fetch(const TokenRequestOptions()); + expect(fetchCount, 1); + expect(result2.participantToken, result1.participantToken); + }); + + test('refetches when token is expired', () async { + var fetchCount = 0; + final mockSource = _MockTokenSource((options) async { + fetchCount++; + final token = fetchCount == 1 ? _generateExpiredToken() : _generateValidToken(); + return TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + }); + + final cachingSource = CachingTokenSource(mockSource); + + // First fetch with expired token + await cachingSource.fetch(const TokenRequestOptions()); + expect(fetchCount, 1); + + // Second fetch should refetch due to expiration + await cachingSource.fetch(const TokenRequestOptions()); + expect(fetchCount, 2); + }); + + test('refetches when options change', () async { + var fetchCount = 0; + final mockSource = _MockTokenSource((options) async { + fetchCount++; + final token = _generateValidToken(); + return TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + }); + + final cachingSource = CachingTokenSource(mockSource); + + // First fetch with initial options + await cachingSource.fetch(const TokenRequestOptions(roomName: 'room1')); + expect(fetchCount, 1); + + // Fetch with same options should use cache + await cachingSource.fetch(const TokenRequestOptions(roomName: 'room1')); + expect(fetchCount, 1); + + // Fetch with different options should refetch + await cachingSource.fetch(const TokenRequestOptions(roomName: 'room2')); + expect(fetchCount, 2); + }); + + test('refetches when participant metadata changes', () async { + var fetchCount = 0; + final mockSource = _MockTokenSource((options) async { + fetchCount++; + final token = _generateValidToken(); + return TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + }); + + final cachingSource = CachingTokenSource(mockSource); + + await cachingSource.fetch(const TokenRequestOptions(participantMetadata: 'meta1')); + expect(fetchCount, 1); + + await cachingSource.fetch(const TokenRequestOptions(participantMetadata: 'meta2')); + expect(fetchCount, 2); + }); + + test('refetches when participant attributes change', () async { + var fetchCount = 0; + final mockSource = _MockTokenSource((options) async { + fetchCount++; + final token = _generateValidToken(); + return TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + }); + + final cachingSource = CachingTokenSource(mockSource); + + await cachingSource.fetch(const TokenRequestOptions( + participantAttributes: {'key1': 'value1'}, + )); + expect(fetchCount, 1); + + await cachingSource.fetch(const TokenRequestOptions( + participantAttributes: {'key1': 'value2'}, + )); + expect(fetchCount, 2); + }); + + test('refetches when agentName changes', () async { + var fetchCount = 0; + final mockSource = _MockTokenSource((options) async { + fetchCount++; + final token = _generateValidToken(); + return TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + }); + + final cachingSource = CachingTokenSource(mockSource); + + await cachingSource.fetch(const TokenRequestOptions(agentName: 'agent1')); + expect(fetchCount, 1); + + await cachingSource.fetch(const TokenRequestOptions(agentName: 'agent2')); + expect(fetchCount, 2); + }); + + test('refetches when agentMetadata changes', () async { + var fetchCount = 0; + final mockSource = _MockTokenSource((options) async { + fetchCount++; + final token = _generateValidToken(); + return TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + }); + + final cachingSource = CachingTokenSource(mockSource); + + await cachingSource.fetch(const TokenRequestOptions(agentMetadata: 'meta1')); + expect(fetchCount, 1); + + await cachingSource.fetch(const TokenRequestOptions(agentMetadata: 'meta2')); + expect(fetchCount, 2); + }); + + test('handles concurrent fetches with single request', () async { + var fetchCount = 0; + final mockSource = _MockTokenSource((options) async { + fetchCount++; + await Future.delayed(Duration(milliseconds: 100)); + final token = _generateValidToken(); + return TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + }); + + final cachingSource = CachingTokenSource(mockSource); + + // Start multiple concurrent fetches + final futures = List.generate(5, (_) => cachingSource.fetch(const TokenRequestOptions())); + final results = await Future.wait(futures); + + // Should only fetch once despite concurrent requests + expect(fetchCount, 1); + expect(results.every((r) => r.participantToken == results.first.participantToken), isTrue); + }); + + test('concurrent fetches with different options fetch independently', () async { + var fetchCount = 0; + final mockSource = _MockTokenSource((options) async { + fetchCount++; + await Future.delayed(Duration(milliseconds: 50)); + final token = _generateValidToken(); + return TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: '$token-${options.roomName}', + ); + }); + + final cachingSource = CachingTokenSource(mockSource); + + final futureOne = cachingSource.fetch(const TokenRequestOptions(roomName: 'room-a')); + final futureTwo = cachingSource.fetch(const TokenRequestOptions(roomName: 'room-b')); + + final responses = await Future.wait([futureOne, futureTwo]); + + expect(fetchCount, 2); + expect(responses[0].participantToken == responses[1].participantToken, isFalse); + }); + + test('invalidate clears cache', () async { + var fetchCount = 0; + final mockSource = _MockTokenSource((options) async { + fetchCount++; + final token = _generateValidToken(); + return TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + }); + + final cachingSource = CachingTokenSource(mockSource); + + await cachingSource.fetch(const TokenRequestOptions()); + expect(fetchCount, 1); + + // Invalidate cache + await cachingSource.invalidate(); + + // Should refetch after invalidation + await cachingSource.fetch(const TokenRequestOptions()); + expect(fetchCount, 2); + }); + + test('cachedResponse returns current cached response', () async { + final mockSource = _MockTokenSource((options) async { + final token = _generateValidToken(); + return TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + }); + + final cachingSource = CachingTokenSource(mockSource); + + expect(await cachingSource.cachedResponse(), isNull); + + final response = await cachingSource.fetch(const TokenRequestOptions()); + expect(await cachingSource.cachedResponse(), isNotNull); + expect((await cachingSource.cachedResponse())?.participantToken, response.participantToken); + + await cachingSource.invalidate(); + expect(await cachingSource.cachedResponse(), isNull); + }); + + test('custom validator is respected', () async { + var fetchCount = 0; + final mockSource = _MockTokenSource((options) async { + fetchCount++; + final token = _generateValidToken(); + return TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + }); + + // Custom validator that only caches when participantName is 'charlie' + bool customValidator(TokenRequestOptions? options, TokenSourceResponse response) { + return options?.participantName == 'charlie' && response.participantToken.isNotEmpty; + } + + final cachingSource = CachingTokenSource( + mockSource, + validator: customValidator, + ); + + // First fetch with matching validator + const charlieOptions = TokenRequestOptions( + roomName: 'test-room', + participantName: 'charlie', + ); + final result1 = await cachingSource.fetch(charlieOptions); + expect(fetchCount, 1); + + // Second fetch with same options should use cache (validator returns true) + final result2 = await cachingSource.fetch(charlieOptions); + expect(fetchCount, 1); + expect(result2.participantToken, result1.participantToken); + + // Fetch with different participantName should refetch (validator returns false) + const aliceOptions = TokenRequestOptions( + roomName: 'test-room', + participantName: 'alice', + ); + await cachingSource.fetch(aliceOptions); + expect(fetchCount, 2); + + // Fetch again with alice should refetch again (validator always returns false for alice) + await cachingSource.fetch(aliceOptions); + expect(fetchCount, 3); + }); + + test('cached extension creates CachingTokenSource', () { + final mockSource = _MockTokenSource((options) async { + return TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: _generateValidToken(), + ); + }); + + final cachedSource = mockSource.cached(); + expect(cachedSource, isA()); + }); + }); +} + +class _MockTokenSource implements TokenSourceConfigurable { + final Future Function(TokenRequestOptions options) _fetchFn; + + _MockTokenSource(this._fetchFn); + + @override + Future fetch(TokenRequestOptions options) => _fetchFn(options); +} + +String _generateValidToken() { + final now = DateTime.timestamp(); + final exp = (now.millisecondsSinceEpoch ~/ 1000) + 3600; // +1 hour + + final payload = { + 'sub': 'test-participant', + 'video': {'room': 'test-room', 'roomJoin': true}, + 'exp': exp, + }; + + final jwt = JWT(payload); + return jwt.sign(SecretKey('test-secret')); +} + +String _generateExpiredToken() { + final now = DateTime.timestamp(); + final exp = (now.millisecondsSinceEpoch ~/ 1000) - 3600; // -1 hour + + final payload = { + 'sub': 'test-participant', + 'video': {'room': 'test-room', 'roomJoin': true}, + 'exp': exp, + }; + + final jwt = JWT(payload); + return jwt.sign(SecretKey('test-secret')); +} diff --git a/test/token/endpoint_token_source_test.dart b/test/token/endpoint_token_source_test.dart new file mode 100644 index 00000000..fd1da8dc --- /dev/null +++ b/test/token/endpoint_token_source_test.dart @@ -0,0 +1,440 @@ +// Copyright 2024 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. + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'package:livekit_client/src/token_source/endpoint.dart'; +import 'package:livekit_client/src/token_source/room_configuration.dart'; +import 'package:livekit_client/src/token_source/token_source.dart'; + +void main() { + group('EndpointTokenSource HTTP Tests', () { + test('POST endpoint with agentName and agentMetadata in room_config.agents', () async { + http.Request? capturedRequest; + + final mockClient = MockClient((request) async { + capturedRequest = request; + return http.Response( + jsonEncode({ + 'server_url': 'wss://www.example.com', + 'room_name': 'room-name', + 'participant_name': 'participant-name', + 'participant_token': 'token', + }), + 200, + ); + }); + + final source = EndpointTokenSource( + url: Uri.parse('https://example.com/token'), + method: 'POST', + headers: {'hello': 'world'}, + client: mockClient, + ); + + const options = TokenRequestOptions( + roomName: 'room-name', + participantName: 'participant-name', + participantIdentity: 'participant-identity', + participantMetadata: 'participant-metadata', + agentName: 'agent-name', + agentMetadata: 'agent-metadata', + ); + + final response = await source.fetch(options); + + expect(response.serverUrl, 'wss://www.example.com'); + expect(response.participantToken, 'token'); + expect(response.participantName, 'participant-name'); + expect(response.roomName, 'room-name'); + + expect(capturedRequest, isNotNull); + expect(capturedRequest!.method, 'POST'); + expect(capturedRequest!.headers['hello'], 'world'); + expect(capturedRequest!.headers['Content-Type'], contains('application/json')); + + final requestBody = jsonDecode(capturedRequest!.body) as Map; + + expect(requestBody['room_name'], 'room-name'); + expect(requestBody['participant_name'], 'participant-name'); + expect(requestBody['participant_identity'], 'participant-identity'); + expect(requestBody['participant_metadata'], 'participant-metadata'); + + expect(requestBody['room_config'], isNotNull); + expect(requestBody['room_config']['agents'], isList); + + final agents = requestBody['room_config']['agents'] as List; + expect(agents.length, 1); + expect(agents[0]['agent_name'], 'agent-name'); + expect(agents[0]['metadata'], 'agent-metadata'); + }); + + test('GET endpoint with body', () async { + http.Request? capturedRequest; + + final mockClient = MockClient((request) async { + capturedRequest = request; + return http.Response( + jsonEncode({ + 'server_url': 'wss://www.example.com', + 'participant_token': 'token', + }), + 200, + ); + }); + + final source = EndpointTokenSource( + url: Uri.parse('https://example.com/token'), + method: 'GET', + client: mockClient, + ); + + final response = await source.fetch(const TokenRequestOptions()); + + expect(response.serverUrl, 'wss://www.example.com'); + expect(response.participantToken, 'token'); + + expect(capturedRequest, isNotNull); + expect(capturedRequest!.method, 'GET'); + // Body is always sent even for GET requests + expect(capturedRequest!.body, '{"room_config":{}}'); + }); + + test('accepts non-200 success responses', () async { + final mockClient = MockClient((request) async { + return http.Response( + jsonEncode({ + 'server_url': 'wss://www.example.com', + 'participant_token': 'token', + }), + 201, + ); + }); + + final source = EndpointTokenSource( + url: Uri.parse('https://example.com/token'), + client: mockClient, + ); + + final response = await source.fetch(const TokenRequestOptions()); + + expect(response.serverUrl, 'wss://www.example.com'); + expect(response.participantToken, 'token'); + }); + + test('camelCase backward compatibility', () async { + final mockClient = MockClient((request) async { + return http.Response( + jsonEncode({ + 'serverUrl': 'wss://www.example.com', + 'roomName': 'room-name', + 'participantName': 'participant-name', + 'participantToken': 'token', + }), + 200, + ); + }); + + final source = EndpointTokenSource( + url: Uri.parse('https://example.com/token'), + client: mockClient, + ); + + final response = await source.fetch(const TokenRequestOptions()); + + expect(response.serverUrl, 'wss://www.example.com'); + expect(response.participantToken, 'token'); + expect(response.participantName, 'participant-name'); + expect(response.roomName, 'room-name'); + }); + + test('missing optional keys default to null', () async { + final mockClient = MockClient((request) async { + return http.Response( + jsonEncode({ + 'server_url': 'wss://www.example.com', + 'participant_token': 'token', + }), + 200, + ); + }); + + final source = EndpointTokenSource( + url: Uri.parse('https://example.com/token'), + client: mockClient, + ); + + final response = await source.fetch(const TokenRequestOptions()); + + expect(response.serverUrl, 'wss://www.example.com'); + expect(response.participantToken, 'token'); + expect(response.participantName, isNull); + expect(response.roomName, isNull); + }); + + test('error response throws structured exception', () async { + final mockClient = MockClient((request) async { + return http.Response('Not Found', 404); + }); + + final source = EndpointTokenSource( + url: Uri.parse('https://example.com/token'), + client: mockClient, + ); + + expect( + () => source.fetch(const TokenRequestOptions()), + throwsA( + isA() + .having((e) => e.statusCode, 'statusCode', 404) + .having((e) => e.body, 'body', 'Not Found') + .having((e) => e.uri.toString(), 'uri', 'https://example.com/token'), + ), + ); + }); + + test('server error response throws structured exception', () async { + final mockClient = MockClient((request) async { + return http.Response('Internal Server Error', 500); + }); + + final source = EndpointTokenSource( + url: Uri.parse('https://example.com/token'), + client: mockClient, + ); + + expect( + () => source.fetch(const TokenRequestOptions()), + throwsA( + isA() + .having((e) => e.statusCode, 'statusCode', 500) + .having((e) => e.body, 'body', 'Internal Server Error'), + ), + ); + }); + }); + + group('TokenRequestOptions Serialization', () { + test('toRequest().toJson() includes agentName and agentMetadata in room_config.agents', () { + const options = TokenRequestOptions( + roomName: 'test-room', + participantName: 'test-participant', + agentName: 'test-agent', + agentMetadata: '{"key":"value"}', + ); + + final json = options.toRequest().toJson(); + + expect(json['room_name'], 'test-room'); + expect(json['participant_name'], 'test-participant'); + expect(json['room_config'], isNotNull); + expect(json['room_config']['agents'], isNotNull); + expect(json['room_config']['agents'], isList); + expect((json['room_config']['agents'] as List).length, 1); + expect((json['room_config']['agents'] as List)[0]['agent_name'], 'test-agent'); + expect((json['room_config']['agents'] as List)[0]['metadata'], '{"key":"value"}'); + }); + + test('toRequest().toJson() wraps only agentName in room_config.agents', () { + const options = TokenRequestOptions( + agentName: 'test-agent', + ); + + final json = options.toRequest().toJson(); + + expect(json['room_config'], isNotNull); + expect(json['room_config']['agents'], isNotNull); + expect((json['room_config']['agents'] as List).length, 1); + expect((json['room_config']['agents'] as List)[0]['agent_name'], 'test-agent'); + expect((json['room_config']['agents'] as List)[0].containsKey('metadata'), isFalse); + }); + + test('toRequest().toJson() wraps only agentMetadata in room_config.agents', () { + const options = TokenRequestOptions( + agentMetadata: '{"key":"value"}', + ); + + final json = options.toRequest().toJson(); + + expect(json['room_config'], isNotNull); + expect(json['room_config']['agents'], isNotNull); + expect((json['room_config']['agents'] as List).length, 1); + expect((json['room_config']['agents'] as List)[0].containsKey('agent_name'), isFalse); + expect((json['room_config']['agents'] as List)[0]['metadata'], '{"key":"value"}'); + }); + + test('includes participant attributes', () { + const options = TokenRequestOptions( + participantAttributes: { + 'key1': 'value1', + 'key2': 'value2', + }, + ); + + final json = options.toRequest().toJson(); + + expect(json['participant_attributes'], isMap); + expect(json['participant_attributes']['key1'], 'value1'); + expect(json['participant_attributes']['key2'], 'value2'); + }); + + test('handles empty options', () { + const options = TokenRequestOptions(); + + final json = options.toRequest().toJson(); + + expect(json.keys, contains('room_config')); + expect(json['room_config'], isMap); + expect((json['room_config'] as Map), isEmpty); + }); + + test('only includes non-null fields', () { + const options = TokenRequestOptions( + roomName: 'test-room', + participantName: null, + participantIdentity: 'test-identity', + ); + + final json = options.toRequest().toJson(); + + expect(json.containsKey('room_name'), isTrue); + expect(json.containsKey('participant_name'), isFalse); + expect(json.containsKey('participant_identity'), isTrue); + expect(json.containsKey('room_config'), isTrue); + expect((json['room_config'] as Map), isEmpty); + }); + }); + + group('TokenSourceResponse', () { + test('fromJson parses all fields', () { + final json = { + 'server_url': 'https://test.livekit.io', + 'participant_token': 'test-token', + 'participant_name': 'test-participant', + 'room_name': 'test-room', + }; + + final response = TokenSourceResponse.fromJson(json); + + expect(response.serverUrl, 'https://test.livekit.io'); + expect(response.participantToken, 'test-token'); + expect(response.participantName, 'test-participant'); + expect(response.roomName, 'test-room'); + }); + + test('fromJson handles missing optional fields', () { + final json = { + 'server_url': 'https://test.livekit.io', + 'participant_token': 'test-token', + }; + + final response = TokenSourceResponse.fromJson(json); + + expect(response.serverUrl, 'https://test.livekit.io'); + expect(response.participantToken, 'test-token'); + expect(response.participantName, isNull); + expect(response.roomName, isNull); + }); + }); + + group('RoomConfiguration', () { + test('toJson includes all fields', () { + const config = RoomConfiguration( + name: 'test-room', + emptyTimeout: 300, + departureTimeout: 60, + maxParticipants: 10, + metadata: 'test-metadata', + minPlayoutDelay: 100, + maxPlayoutDelay: 500, + syncStreams: true, + agents: [ + RoomAgentDispatch( + agentName: 'test-agent', + metadata: '{"key":"value"}', + ), + ], + ); + + final json = config.toJson(); + + expect(json['name'], 'test-room'); + expect(json['empty_timeout'], 300); + expect(json['departure_timeout'], 60); + expect(json['max_participants'], 10); + expect(json['metadata'], 'test-metadata'); + expect(json['min_playout_delay'], 100); + expect(json['max_playout_delay'], 500); + expect(json['sync_streams'], true); + expect(json['agents'], isList); + expect((json['agents'] as List).length, 1); + }); + + test('toJson only includes non-null fields', () { + const config = RoomConfiguration( + name: 'test-room', + maxParticipants: 10, + ); + + final json = config.toJson(); + + expect(json.containsKey('name'), isTrue); + expect(json.containsKey('max_participants'), isTrue); + expect(json.containsKey('empty_timeout'), isFalse); + expect(json.containsKey('departure_timeout'), isFalse); + expect(json.containsKey('metadata'), isFalse); + expect(json.containsKey('min_playout_delay'), isFalse); + expect(json.containsKey('max_playout_delay'), isFalse); + expect(json.containsKey('sync_streams'), isFalse); + expect(json.containsKey('agents'), isFalse); + }); + }); + + group('RoomAgentDispatch', () { + test('toJson includes all fields', () { + const dispatch = RoomAgentDispatch( + agentName: 'test-agent', + metadata: '{"key":"value"}', + ); + + final json = dispatch.toJson(); + + expect(json['agent_name'], 'test-agent'); + expect(json['metadata'], '{"key":"value"}'); + }); + + test('toJson only includes non-null fields', () { + const dispatch = RoomAgentDispatch( + agentName: 'test-agent', + ); + + final json = dispatch.toJson(); + + expect(json.containsKey('agent_name'), isTrue); + expect(json.containsKey('metadata'), isFalse); + }); + + test('toJson handles both fields as null', () { + const dispatch = RoomAgentDispatch(); + + final json = dispatch.toJson(); + + expect(json.isEmpty, isTrue); + }); + }); +} diff --git a/test/token/token_source_test.dart b/test/token/token_source_test.dart new file mode 100644 index 00000000..40f684d1 --- /dev/null +++ b/test/token/token_source_test.dart @@ -0,0 +1,488 @@ +// Copyright 2024 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. + +import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livekit_client/src/token_source/custom.dart'; +import 'package:livekit_client/src/token_source/jwt.dart'; +import 'package:livekit_client/src/token_source/literal.dart'; +import 'package:livekit_client/src/token_source/room_configuration.dart'; +import 'package:livekit_client/src/token_source/sandbox.dart'; +import 'package:livekit_client/src/token_source/token_source.dart'; + +void main() { + group('JWT Validation', () { + test('valid token returns false for isResponseExpired', () { + final now = DateTime.timestamp(); + final exp = (now.millisecondsSinceEpoch ~/ 1000) + 3600; // +1 hour + + final token = _generateToken(exp: exp); + final response = TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + + expect(isResponseExpired(response), isFalse); + }); + + test('expired token returns true for isResponseExpired', () { + final now = DateTime.timestamp(); + final exp = (now.millisecondsSinceEpoch ~/ 1000) - 3600; // -1 hour + + final token = _generateToken(exp: exp); + final response = TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + + expect(isResponseExpired(response), isTrue); + }); + + test('token within 60s tolerance returns true for isResponseExpired', () { + final now = DateTime.timestamp(); + final exp = (now.millisecondsSinceEpoch ~/ 1000) + 30; // +30 seconds + + final token = _generateToken(exp: exp); + final response = TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + + expect(isResponseExpired(response), isTrue); + }); + + test('token before nbf returns true for isResponseExpired', () { + final now = DateTime.timestamp(); + final nbf = (now.millisecondsSinceEpoch ~/ 1000) + 3600; // +1 hour + final exp = (now.millisecondsSinceEpoch ~/ 1000) + 7200; // +2 hours + + final token = _generateToken(nbf: nbf, exp: exp); + final response = TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + + expect(isResponseExpired(response), isTrue); + }); + + test('token without exp returns true for isResponseExpired', () { + final token = _generateToken(includeExp: false); + final response = TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + + expect(isResponseExpired(response), isTrue); + }); + + test('invalid token returns true for isResponseExpired', () { + final response = TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: 'invalid.token.here', + ); + + expect(isResponseExpired(response), isTrue); + }); + + test('hasValidToken returns true for valid token', () { + final now = DateTime.timestamp(); + final exp = (now.millisecondsSinceEpoch ~/ 1000) + 3600; + + final token = _generateToken(exp: exp); + final response = TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + + expect(response.hasValidToken(currentTime: now), isTrue); + }); + + test('hasValidToken returns false when exp is within tolerance window', () { + final now = DateTime.timestamp(); + final exp = (now.millisecondsSinceEpoch ~/ 1000) + 30; // +30 seconds + + final token = _generateToken(exp: exp); + final response = TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + + expect(response.hasValidToken(currentTime: now), isFalse); + }); + + test('hasValidToken respects custom tolerance', () { + final now = DateTime.timestamp(); + final exp = (now.millisecondsSinceEpoch ~/ 1000) + 30; // +30 seconds + + final token = _generateToken(exp: exp); + final response = TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + + expect(response.hasValidToken(tolerance: const Duration(seconds: 10), currentTime: now), isTrue); + expect(response.hasValidToken(tolerance: const Duration(seconds: 60), currentTime: now), isFalse); + }); + + test('hasValidToken respects not-before claim', () { + final now = DateTime.timestamp(); + final nbf = (now.millisecondsSinceEpoch ~/ 1000) + 120; // +2 minutes + final exp = (now.millisecondsSinceEpoch ~/ 1000) + 3600; // +1 hour + + final token = _generateToken(nbf: nbf, exp: exp); + final response = TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + + expect(response.hasValidToken(currentTime: now), isFalse); + expect(response.hasValidToken(currentTime: now.add(const Duration(minutes: 5))), isTrue); + }); + }); + + group('LiveKitJwtPayload', () { + test('parses claims and grants from token', () { + final now = DateTime.timestamp(); + final exp = (now.millisecondsSinceEpoch ~/ 1000) + 3600; + final token = _generateToken( + exp: exp, + iat: now.millisecondsSinceEpoch ~/ 1000, + issuer: 'livekit', + subject: 'participant-123', + name: 'Alice', + metadata: '{"key":"value"}', + attributes: {'role': 'host'}, + video: { + 'room': 'demo-room', + 'room_join': true, + 'room_create': true, + 'can_publish': true, + 'can_publish_data': true, + 'can_publish_sources': ['camera', 'screen'], + 'hidden': false, + 'recorder': true, + }, + ); + + final response = TokenSourceResponse( + serverUrl: 'https://test.livekit.io', + participantToken: token, + ); + + final payload = response.jwtPayload; + expect(payload, isNotNull); + expect(payload!.issuer, 'livekit'); + expect(payload.identity, 'participant-123'); + expect(payload.name, 'Alice'); + expect(payload.metadata, '{"key":"value"}'); + expect(payload.attributes, {'role': 'host'}); + expect(payload.expiresAt, isNotNull); + expect(payload.issuedAt, isNotNull); + + final grant = payload.video; + expect(grant, isNotNull); + expect(grant!.room, 'demo-room'); + expect(grant.roomJoin, isTrue); + expect(grant.roomCreate, isTrue); + expect(grant.canPublish, isTrue); + expect(grant.canPublishData, isTrue); + expect(grant.canPublishSources, ['camera', 'screen']); + expect(grant.hidden, isFalse); + expect(grant.recorder, isTrue); + }); + }); + + group('SandboxTokenSource', () { + test('sanitizes sandbox id and uses default base URL', () { + final source = SandboxTokenSource(sandboxId: ' sandbox-123 '); + + expect(source.uri.toString(), 'https://cloud-api.livekit.io/api/v2/sandbox/connection-details'); + expect(source.headers['X-Sandbox-ID'], 'sandbox-123'); + }); + }); + + group('LiteralTokenSource', () { + test('returns fixed response', () async { + final source = LiteralTokenSource( + serverUrl: 'https://test.livekit.io', + participantToken: 'test-token', + participantName: 'test-participant', + roomName: 'test-room', + ); + + final result = await source.fetch(); + + expect(result.serverUrl, 'https://test.livekit.io'); + expect(result.participantToken, 'test-token'); + expect(result.participantName, 'test-participant'); + expect(result.roomName, 'test-room'); + }); + + test('returns fixed response with minimal parameters', () async { + final source = LiteralTokenSource( + serverUrl: 'https://test.livekit.io', + participantToken: 'test-token', + ); + + final result = await source.fetch(); + + expect(result.serverUrl, 'https://test.livekit.io'); + expect(result.participantToken, 'test-token'); + expect(result.participantName, isNull); + expect(result.roomName, isNull); + }); + }); + + group('CustomTokenSource', () { + test('calls custom function with options', () async { + Future customFunction(TokenRequestOptions options) async { + return TokenSourceResponse( + serverUrl: 'https://custom.livekit.io', + participantToken: 'custom-token', + participantName: options.participantName, + roomName: options.roomName, + ); + } + + final source = CustomTokenSource(customFunction); + final result = await source.fetch(const TokenRequestOptions( + participantName: 'custom-participant', + roomName: 'custom-room', + )); + + expect(result.serverUrl, 'https://custom.livekit.io'); + expect(result.participantToken, 'custom-token'); + expect(result.participantName, 'custom-participant'); + expect(result.roomName, 'custom-room'); + }); + + test('handles null options', () async { + Future customFunction(TokenRequestOptions options) async { + return const TokenSourceResponse( + serverUrl: 'https://custom.livekit.io', + participantToken: 'custom-token', + ); + } + + final source = CustomTokenSource(customFunction); + final result = await source.fetch(const TokenRequestOptions()); + + expect(result.serverUrl, 'https://custom.livekit.io'); + expect(result.participantToken, 'custom-token'); + }); + }); + + group('TokenRequestOptions', () { + test('toRequest().toJson() includes agentName and agentMetadata in room_config.agents', () { + const options = TokenRequestOptions( + roomName: 'test-room', + participantName: 'test-participant', + agentName: 'test-agent', + agentMetadata: '{"key":"value"}', + ); + + final json = options.toRequest().toJson(); + + expect(json['room_name'], 'test-room'); + expect(json['participant_name'], 'test-participant'); + expect(json['room_config'], isNotNull); + expect(json['room_config']['agents'], isNotNull); + expect(json['room_config']['agents'], isList); + expect((json['room_config']['agents'] as List).length, 1); + expect((json['room_config']['agents'] as List)[0]['agent_name'], 'test-agent'); + expect((json['room_config']['agents'] as List)[0]['metadata'], '{"key":"value"}'); + }); + + test('toRequest().toJson() wraps only agentName in room_config.agents', () { + const options = TokenRequestOptions( + agentName: 'test-agent', + ); + + final json = options.toRequest().toJson(); + + expect(json['room_config'], isNotNull); + expect(json['room_config']['agents'], isNotNull); + expect((json['room_config']['agents'] as List).length, 1); + expect((json['room_config']['agents'] as List)[0]['agent_name'], 'test-agent'); + expect((json['room_config']['agents'] as List)[0].containsKey('metadata'), isFalse); + }); + + test('toRequest().toJson() wraps only agentMetadata in room_config.agents', () { + const options = TokenRequestOptions( + agentMetadata: '{"key":"value"}', + ); + + final json = options.toRequest().toJson(); + + expect(json['room_config'], isNotNull); + expect(json['room_config']['agents'], isNotNull); + expect((json['room_config']['agents'] as List).length, 1); + expect((json['room_config']['agents'] as List)[0].containsKey('agent_name'), isFalse); + expect((json['room_config']['agents'] as List)[0]['metadata'], '{"key":"value"}'); + }); + }); + + group('TokenSourceRequest', () { + test('toRequest() wraps agentName and agentMetadata in room_config.agents', () { + const options = TokenRequestOptions( + roomName: 'test-room', + participantName: 'test-participant', + agentName: 'test-agent', + agentMetadata: '{"key":"value"}', + ); + + final request = options.toRequest(); + + expect(request.roomName, 'test-room'); + expect(request.participantName, 'test-participant'); + expect(request.roomConfiguration, isNotNull); + expect(request.roomConfiguration!.agents, isNotNull); + expect(request.roomConfiguration!.agents!.length, 1); + expect(request.roomConfiguration!.agents![0].agentName, 'test-agent'); + expect(request.roomConfiguration!.agents![0].metadata, '{"key":"value"}'); + }); + + test('toRequest() creates empty roomConfiguration when no agent fields', () { + const options = TokenRequestOptions( + roomName: 'test-room', + participantName: 'test-participant', + ); + + final request = options.toRequest(); + + expect(request.roomName, 'test-room'); + expect(request.participantName, 'test-participant'); + expect(request.roomConfiguration, isNotNull); + expect(request.roomConfiguration!.agents, isNull); + }); + + test('TokenSourceRequest.toJson() produces correct wire format', () { + final request = TokenSourceRequest( + roomName: 'test-room', + participantName: 'test-participant', + participantIdentity: 'test-identity', + participantMetadata: 'test-metadata', + participantAttributes: {'key1': 'value1'}, + roomConfiguration: const RoomConfiguration( + agents: [ + RoomAgentDispatch( + agentName: 'test-agent', + metadata: '{"key":"value"}', + ), + ], + ), + ); + + final json = request.toJson(); + + expect(json['room_name'], 'test-room'); + expect(json['participant_name'], 'test-participant'); + expect(json['participant_identity'], 'test-identity'); + expect(json['participant_metadata'], 'test-metadata'); + expect(json['participant_attributes'], {'key1': 'value1'}); + expect(json['room_config'], isNotNull); + expect(json['room_config']['agents'], isNotNull); + expect((json['room_config']['agents'] as List).length, 1); + expect((json['room_config']['agents'] as List)[0]['agent_name'], 'test-agent'); + expect((json['room_config']['agents'] as List)[0]['metadata'], '{"key":"value"}'); + }); + + test('TokenSourceRequest.toJson() only includes non-null fields', () { + const request = TokenSourceRequest( + roomName: 'test-room', + ); + + final json = request.toJson(); + + expect(json.containsKey('room_name'), isTrue); + expect(json.containsKey('participant_name'), isFalse); + expect(json.containsKey('participant_identity'), isFalse); + expect(json.containsKey('participant_metadata'), isFalse); + expect(json.containsKey('participant_attributes'), isFalse); + expect(json.containsKey('room_config'), isFalse); + }); + + test('toRequest() preserves all fields', () { + const options = TokenRequestOptions( + roomName: 'test-room', + participantName: 'test-participant', + participantIdentity: 'test-identity', + participantMetadata: 'test-metadata', + participantAttributes: {'key1': 'value1', 'key2': 'value2'}, + ); + + final request = options.toRequest(); + + expect(request.roomName, 'test-room'); + expect(request.participantName, 'test-participant'); + expect(request.participantIdentity, 'test-identity'); + expect(request.participantMetadata, 'test-metadata'); + expect(request.participantAttributes, {'key1': 'value1', 'key2': 'value2'}); + }); + }); +} + +String _generateToken({ + int? nbf, + int? exp, + bool includeExp = true, + int? iat, + String? issuer, + String? subject, + String? name, + String? metadata, + Map? attributes, + Map? video, +}) { + final payload = { + 'sub': subject ?? 'test-participant', + 'video': video ?? + { + 'room': 'test-room', + 'room_join': true, + }, + }; + + if (issuer != null) { + payload['iss'] = issuer; + } + + if (nbf != null) { + payload['nbf'] = nbf; + } + + if (includeExp && exp != null) { + payload['exp'] = exp; + } + + if (iat != null) { + payload['iat'] = iat; + } + + if (name != null) { + payload['name'] = name; + } + + if (metadata != null) { + payload['metadata'] = metadata; + } + + if (attributes != null) { + payload['attributes'] = Map.from(attributes); + } + + final jwt = JWT(payload); + return jwt.sign(SecretKey('test-secret')); +}