diff --git a/.changes/connection-credentials b/.changes/connection-credentials new file mode 100644 index 000000000..37cbf36c7 --- /dev/null +++ b/.changes/connection-credentials @@ -0,0 +1 @@ +patch type="added" "Abstract token source for easier token fetching in production and faster integration with sandbox environment" diff --git a/Package.swift b/Package.swift index 2985bde67..cf1364965 100644 --- a/Package.swift +++ b/Package.swift @@ -22,10 +22,9 @@ let package = Package( .package(url: "https://github.com/livekit/webrtc-xcframework.git", exact: "137.7151.09"), .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.31.0"), .package(url: "https://github.com/apple/swift-collections.git", "1.1.0" ..< "1.3.0"), + .package(url: "https://github.com/vapor/jwt-kit.git", from: "4.13.5"), // Only used for DocC generation .package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.3.0"), - // Only used for Testing - .package(url: "https://github.com/vapor/jwt-kit.git", from: "4.13.4"), ], targets: [ .target( @@ -39,6 +38,7 @@ let package = Package( .product(name: "SwiftProtobuf", package: "swift-protobuf"), .product(name: "DequeModule", package: "swift-collections"), .product(name: "OrderedCollections", package: "swift-collections"), + .product(name: "JWTKit", package: "jwt-kit"), "LKObjCHelpers", ], exclude: [ @@ -55,7 +55,6 @@ let package = Package( name: "LiveKitTestSupport", dependencies: [ "LiveKit", - .product(name: "JWTKit", package: "jwt-kit"), ], path: "Tests/LiveKitTestSupport" ), diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index 446353301..6f101cb2e 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -23,10 +23,9 @@ let package = Package( .package(url: "https://github.com/livekit/webrtc-xcframework.git", exact: "137.7151.09"), .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.31.0"), .package(url: "https://github.com/apple/swift-collections.git", "1.1.0" ..< "1.3.0"), + .package(url: "https://github.com/vapor/jwt-kit.git", from: "4.13.5"), // Only used for DocC generation .package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.3.0"), - // Only used for Testing - .package(url: "https://github.com/vapor/jwt-kit.git", from: "4.13.4"), ], targets: [ .target( @@ -40,6 +39,7 @@ let package = Package( .product(name: "SwiftProtobuf", package: "swift-protobuf"), .product(name: "DequeModule", package: "swift-collections"), .product(name: "OrderedCollections", package: "swift-collections"), + .product(name: "JWTKit", package: "jwt-kit"), "LKObjCHelpers", ], exclude: [ @@ -56,7 +56,6 @@ let package = Package( name: "LiveKitTestSupport", dependencies: [ "LiveKit", - .product(name: "JWTKit", package: "jwt-kit"), ], path: "Tests/LiveKitTestSupport" ), diff --git a/Sources/LiveKit/Token/CachingTokenSource.swift b/Sources/LiveKit/Token/CachingTokenSource.swift new file mode 100644 index 000000000..861746978 --- /dev/null +++ b/Sources/LiveKit/Token/CachingTokenSource.swift @@ -0,0 +1,167 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// A token source that caches credentials from any other ``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. +public actor CachingTokenSource: TokenSourceConfigurable, Loggable { + /// A tuple containing the request and response that were cached. + public typealias Cached = (TokenRequestOptions, TokenSourceResponse) + + /// A closure that validates whether 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. + public typealias Validator = @Sendable (TokenRequestOptions, TokenSourceResponse) -> Bool + + /// Protocol for storing and retrieving cached token credentials. + /// + /// Implement this protocol to create custom storage solutions like Keychain, + /// or database-backed storage for token caching. + public protocol Store: Sendable { + /// Store credentials in the store. + /// + /// This replaces any existing cached credentials with the new ones. + func store(_ credentials: CachingTokenSource.Cached) async + + /// Retrieve the cached credentials. + /// - Returns: The cached credentials if found, nil otherwise + func retrieve() async -> CachingTokenSource.Cached? + + /// Clear all stored credentials. + func clear() async + } + + private let source: TokenSourceConfigurable + private let store: Store + private let validator: Validator + + /// Initialize a caching wrapper around any token source. + /// + /// - Parameters: + /// - source: The underlying token source to wrap and cache + /// - store: The store implementation to use for caching (defaults to in-memory store) + /// - validator: A closure to determine if cached credentials are still valid (defaults to JWT expiration check) + public init( + _ source: TokenSourceConfigurable, + store: Store = InMemoryTokenStore(), + validator: @escaping Validator = { _, response in response.hasValidToken() } + ) { + self.source = source + self.store = store + self.validator = validator + } + + public func fetch(_ options: TokenRequestOptions) async throws -> TokenSourceResponse { + if let (cachedOptions, cachedResponse) = await store.retrieve(), + cachedOptions == options, + validator(cachedOptions, cachedResponse) + { + log("Using cached credentials", .debug) + return cachedResponse + } + + log("Requesting new credentials", .debug) + let newResponse = try await source.fetch(options) + await store.store((options, newResponse)) + + return newResponse + } + + /// Invalidate the cached credentials, forcing a fresh fetch on the next request. + public func invalidate() async { + await store.clear() + } + + /// Get the cached credentials + /// - Returns: The cached response if found, nil otherwise. + public func cachedResponse() async -> TokenSourceResponse? { + await store.retrieve()?.1 + } +} + +public extension 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 closure to determine if cached credentials are still valid (defaults to JWT expiration check) + /// - Returns: A caching token source that wraps this token source + func cached(store: CachingTokenSource.Store = InMemoryTokenStore(), + validator: @escaping CachingTokenSource.Validator = { _, response in response.hasValidToken() }) -> CachingTokenSource + { + CachingTokenSource(self, store: store, validator: validator) + } +} + +// MARK: - Store + +/// 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, but consider persistent storage for production. +public actor InMemoryTokenStore: CachingTokenSource.Store { + private var cached: CachingTokenSource.Cached? + + public init() {} + + public func store(_ credentials: CachingTokenSource.Cached) async { + cached = credentials + } + + public func retrieve() async -> CachingTokenSource.Cached? { + cached + } + + public func clear() async { + cached = nil + } +} + +// MARK: - Validation + +public extension TokenSourceResponse { + /// Validates whether the JWT token is still valid and not expired. + /// + /// - Parameter tolerance: Time tolerance in seconds for token expiration check (default: 60 seconds) + /// - Returns: `true` if the token is valid and not expired, `false` otherwise + func hasValidToken(withTolerance tolerance: TimeInterval = 60) -> Bool { + guard let jwt = jwt() else { + return false + } + + do { + try jwt.nbf.verifyNotBefore() + try jwt.exp.verifyNotExpired(currentDate: Date().addingTimeInterval(tolerance)) + } catch { + return false + } + + return true + } + + /// Extracts the JWT payload from the participant token. + /// + /// - Returns: The JWT payload if successfully parsed, nil otherwise + func jwt() -> LiveKitJWTPayload? { + LiveKitJWTPayload.fromUnverified(token: participantToken) + } +} diff --git a/Sources/LiveKit/Token/EndpointTokenSource.swift b/Sources/LiveKit/Token/EndpointTokenSource.swift new file mode 100644 index 000000000..71dbff924 --- /dev/null +++ b/Sources/LiveKit/Token/EndpointTokenSource.swift @@ -0,0 +1,64 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// Protocol for token servers that fetch credentials via HTTP requests. +/// Provides a default implementation of `fetch` that can be used to integrate with custom backend token generation endpoints. +/// +/// The default implementation: +/// - Sends a POST request to the specified URL +/// - Encodes the request parameters as ``TokenRequestOptions`` JSON in the request body +/// - Includes any custom headers specified by the implementation +/// - Expects the response to be decoded as ``TokenSourceResponse`` JSON +/// - Validates HTTP status codes (200-299) and throws appropriate errors for failures +public protocol EndpointTokenSource: TokenSourceConfigurable { + /// The URL endpoint for token generation. + /// This should point to your backend service that generates LiveKit tokens. + var url: URL { get } + /// The HTTP method to use for the token request (defaults to "POST"). + var method: String { get } + /// Additional HTTP headers to include with the request. + var headers: [String: String] { get } +} + +public extension EndpointTokenSource { + var method: String { "POST" } + var headers: [String: String] { [:] } + + func fetch(_ options: TokenRequestOptions) async throws -> TokenSourceResponse { + var urlRequest = URLRequest(url: url) + + urlRequest.httpMethod = method + for (key, value) in headers { + urlRequest.addValue(value, forHTTPHeaderField: key) + } + urlRequest.httpBody = try JSONEncoder().encode(options.toRequest()) + urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") + + let (data, response) = try await URLSession.shared.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + throw LiveKitError(.network, message: "Error generating token from the token server, no response") + } + + guard (200 ..< 300).contains(httpResponse.statusCode) else { + throw LiveKitError(.network, message: "Error generating token from the token server, received \(httpResponse)") + } + + return try JSONDecoder().decode(TokenSourceResponse.self, from: data) + } +} diff --git a/Sources/LiveKit/Token/JWT.swift b/Sources/LiveKit/Token/JWT.swift new file mode 100644 index 000000000..cc58ec158 --- /dev/null +++ b/Sources/LiveKit/Token/JWT.swift @@ -0,0 +1,106 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import JWTKit + +/// JWT payload structure for LiveKit authentication tokens. +public struct LiveKitJWTPayload: JWTPayload, Codable, Equatable { + /// Video-specific permissions and room access grants for the participant. + public struct VideoGrant: Codable, Equatable { + /// Name of the room. Required for admin or join permissions. + public let room: String? + /// Permission to create new rooms. + public let roomCreate: Bool? + /// Permission to join a room as a participant. Requires `room` to be set. + public let roomJoin: Bool? + /// Permission to list available rooms. + public let roomList: Bool? + /// Permission to start recording sessions. + public let roomRecord: Bool? + /// Permission to control a specific room. Requires `room` to be set. + public let roomAdmin: Bool? + + /// Allow participant to publish tracks. If neither `canPublish` or `canSubscribe` is set, both are enabled. + public let canPublish: Bool? + /// Allow participant to subscribe to other participants' tracks. + public let canSubscribe: Bool? + /// Allow participant to publish data messages. Defaults to `true` if not set. + public let canPublishData: Bool? + /// Allowed track sources for publishing (e.g., "camera", "microphone", "screen_share"). + public let canPublishSources: [String]? + /// Hide participant from other participants in the room. + public let hidden: Bool? + /// Mark participant as a recorder. When set, allows room to indicate it's being recorded. + public let recorder: Bool? + + public init(room: String? = nil, + roomCreate: Bool? = nil, + roomJoin: Bool? = nil, + roomList: Bool? = nil, + roomRecord: Bool? = nil, + roomAdmin: Bool? = nil, + canPublish: Bool? = nil, + canSubscribe: Bool? = nil, + canPublishData: Bool? = nil, + canPublishSources: [String]? = nil, + hidden: Bool? = nil, + recorder: Bool? = nil) + { + self.room = room + self.roomCreate = roomCreate + self.roomJoin = roomJoin + self.roomList = roomList + self.roomRecord = roomRecord + self.roomAdmin = roomAdmin + self.canPublish = canPublish + self.canSubscribe = canSubscribe + self.canPublishData = canPublishData + self.canPublishSources = canPublishSources + self.hidden = hidden + self.recorder = recorder + } + } + + /// JWT expiration time claim (when the token expires). + public let exp: ExpirationClaim + /// JWT issuer claim (who issued the token). + public let iss: IssuerClaim + /// JWT not-before claim (when the token becomes valid). + public let nbf: NotBeforeClaim + /// JWT subject claim (the participant identity). + public let sub: SubjectClaim + + /// Display name for the participant in the room. + public let name: String? + /// Custom metadata associated with the participant. + public let metadata: String? + /// Video-specific permissions and room access grants. + public let video: VideoGrant? + + /// Verifies the JWT token's validity by checking expiration and not-before claims. + public func verify(using _: JWTSigner) throws { + try nbf.verifyNotBefore() + try exp.verifyNotExpired() + } + + /// Creates a JWT payload from an unverified token string. + /// + /// - Parameter token: The JWT token string to parse + /// - Returns: The parsed JWT payload if successful, nil otherwise + static func fromUnverified(token: String) -> Self? { + try? JWTSigners().unverified(token, as: Self.self) + } +} diff --git a/Sources/LiveKit/Token/LiteralTokenSource.swift b/Sources/LiveKit/Token/LiteralTokenSource.swift new file mode 100644 index 000000000..4c83c23e1 --- /dev/null +++ b/Sources/LiveKit/Token/LiteralTokenSource.swift @@ -0,0 +1,55 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// 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. +/// +/// - Note: For dynamic token fetching, use ``EndpointTokenSource`` or implement ``TokenSourceConfigurable``. +public struct LiteralTokenSource: TokenSourceFixed { + /// The LiveKit server URL to connect to. + let serverURL: URL + /// The JWT token for participant authentication. + let participantToken: String + /// The display name for the participant (optional). + let participantName: String? + /// The name of the room to join (optional). + let roomName: String? + + /// 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) + public init(serverURL: URL, participantToken: String, participantName: String? = nil, roomName: String? = nil) { + self.serverURL = serverURL + self.participantToken = participantToken + self.participantName = participantName + self.roomName = roomName + } + + /// Returns the fixed credentials without any network requests. + /// + /// - Returns: A `TokenSourceResponse` containing the pre-configured credentials + public func fetch() async throws -> TokenSourceResponse { + TokenSourceResponse(serverURL: serverURL, participantToken: participantToken, participantName: participantName, roomName: roomName) + } +} diff --git a/Sources/LiveKit/Token/SandboxTokenSource.swift b/Sources/LiveKit/Token/SandboxTokenSource.swift new file mode 100644 index 000000000..54b76c9ff --- /dev/null +++ b/Sources/LiveKit/Token/SandboxTokenSource.swift @@ -0,0 +1,41 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// 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. +/// - Note: For production use, implement ``EndpointTokenSource`` or your own ``TokenSourceConfigurable``. +public struct SandboxTokenSource: EndpointTokenSource { + public let url = URL(string: "https://cloud-api.livekit.io/api/v2/sandbox/connection-details")! + public var headers: [String: String] { + ["X-Sandbox-ID": id] + } + + /// The sandbox ID provided by LiveKit Cloud for authentication. + public let id: String + + /// Initialize with a sandbox ID from LiveKit Cloud. + /// + /// - Parameter id: The sandbox ID obtained from your LiveKit Cloud project + public init(id: String) { + self.id = id.trimmingCharacters(in: .alphanumerics.inverted) + } +} diff --git a/Sources/LiveKit/Token/TokenSource.swift b/Sources/LiveKit/Token/TokenSource.swift new file mode 100644 index 000000000..4d611a371 --- /dev/null +++ b/Sources/LiveKit/Token/TokenSource.swift @@ -0,0 +1,141 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +// MARK: - Source + +/// A token source that returns a fixed set of credentials without configurable options. +/// +/// This protocol is designed for backwards compatibility with existing authentication infrastructure +/// that doesn't support dynamic room, participant, or agent parameter configuration. +/// +/// - Note: Use ``LiteralTokenSource`` to provide a fixed set of credentials synchronously. +public protocol TokenSourceFixed: Sendable { + func fetch() async throws -> TokenSourceResponse +} + +/// A token source that provides configurable options for room, participant, and agent parameters. +/// +/// This protocol 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](https://cloud.livekit.io/projects/p_/sandbox/templates/token-server) +/// - ``EndpointTokenSource``: For custom backend endpoints using LiveKit's JSON format +/// - ``CachingTokenSource``: For caching credentials (or use the `.cached()` extension) +public protocol TokenSourceConfigurable: Sendable { + func fetch(_ options: TokenRequestOptions) async throws -> TokenSourceResponse +} + +// MARK: - Token + +/// Request parameters for generating connection credentials. +public struct TokenRequestOptions: Sendable, Equatable { + /// The name of the room to connect to. Required for most token generation scenarios. + public let roomName: String? + /// The display name for the participant in the room. Optional but recommended for user experience. + public let participantName: String? + /// A unique identifier for the participant. Used for permissions and room management. + public let participantIdentity: String? + /// Custom metadata associated with the participant. Can be used for user profiles or additional context. + public let participantMetadata: String? + /// Custom attributes for the participant. Useful for storing key-value data like user roles or preferences. + public let participantAttributes: [String: String]? + /// Name of the agent to dispatch + public let agentName: String? + /// Metadata passed to the agent job + public let agentMetadata: String? + + public init( + roomName: String? = nil, + participantName: String? = nil, + participantIdentity: String? = nil, + participantMetadata: String? = nil, + participantAttributes: [String: String]? = nil, + agentName: String? = nil, + agentMetadata: String? = nil + ) { + self.roomName = roomName + self.participantName = participantName + self.participantIdentity = participantIdentity + self.participantMetadata = participantMetadata + self.participantAttributes = participantAttributes + self.agentName = agentName + self.agentMetadata = agentMetadata + } + + func toRequest() -> TokenSourceRequest { + let agents: [RoomAgentDispatch]? = if agentName != nil || agentMetadata != nil { + [RoomAgentDispatch(agentName: agentName, metadata: agentMetadata)] + } else { + nil + } + + return TokenSourceRequest( + roomName: roomName, + participantName: participantName, + participantIdentity: participantIdentity, + participantMetadata: participantMetadata, + participantAttributes: participantAttributes, + roomConfiguration: RoomConfiguration(agents: agents) + ) + } +} + +struct TokenSourceRequest: Sendable, Encodable { + let roomName: String? + let participantName: String? + let participantIdentity: String? + let participantMetadata: String? + let participantAttributes: [String: String]? + let roomConfiguration: RoomConfiguration? + + enum CodingKeys: String, CodingKey { + case roomName = "room_name" + case participantName = "participant_name" + case participantIdentity = "participant_identity" + case participantMetadata = "participant_metadata" + case participantAttributes = "participant_attributes" + case roomConfiguration = "room_config" + } +} + +/// Response containing the credentials needed to connect to a LiveKit room. +public struct TokenSourceResponse: Decodable, Sendable { + /// The WebSocket URL for the LiveKit server. Use this to establish the connection. + public let serverURL: URL + /// The JWT token containing participant permissions and metadata. Required for authentication. + public let participantToken: String + /// The display name for the participant in the room. May be nil if not specified. + public let participantName: String? + /// The name of the room the participant will join. May be nil if not specified. + public let roomName: String? + + enum CodingKeys: String, CodingKey { + case serverURL = "server_url" + case participantToken = "participant_token" + case participantName = "participant_name" + case roomName = "room_name" + } + + public init(serverURL: URL, participantToken: String, participantName: String? = nil, roomName: String? = nil) { + self.serverURL = serverURL + self.participantToken = participantToken + self.participantName = participantName + self.roomName = roomName + } +} diff --git a/Sources/LiveKit/Types/RoomConfiguration.swift b/Sources/LiveKit/Types/RoomConfiguration.swift new file mode 100644 index 000000000..bbf197f6d --- /dev/null +++ b/Sources/LiveKit/Types/RoomConfiguration.swift @@ -0,0 +1,104 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +public struct RoomConfiguration: Encodable, Sendable, Equatable { + /// Room name, used as ID, must be unique + public let name: String? + + /// Number of seconds to keep the room open if no one joins + public let emptyTimeout: UInt32? + + /// Number of seconds to keep the room open after everyone leaves + public let departureTimeout: UInt32? + + /// Limit number of participants that can be in a room, excluding Egress and Ingress participants + public let maxParticipants: UInt32? + + /// Metadata of room + public let metadata: String? + + // Egress configuration ommited, due to complex serialization + + /// Minimum playout delay of subscriber + public let minPlayoutDelay: UInt32? + + /// Maximum playout delay of subscriber + public let maxPlayoutDelay: UInt32? + + /// 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 + public let syncStreams: Bool? + + /// Define agents that should be dispatched to this room + public let agents: [RoomAgentDispatch]? + + enum CodingKeys: String, CodingKey { + case name + case emptyTimeout = "empty_timeout" + case departureTimeout = "departure_timeout" + case maxParticipants = "max_participants" + case metadata + case minPlayoutDelay = "min_playout_delay" + case maxPlayoutDelay = "max_playout_delay" + case syncStreams = "sync_streams" + case agents + } + + public init( + name: String? = nil, + emptyTimeout: UInt32? = nil, + departureTimeout: UInt32? = nil, + maxParticipants: UInt32? = nil, + metadata: String? = nil, + minPlayoutDelay: UInt32? = nil, + maxPlayoutDelay: UInt32? = nil, + syncStreams: Bool? = nil, + agents: [RoomAgentDispatch]? = nil + ) { + self.name = name + self.emptyTimeout = emptyTimeout + self.departureTimeout = departureTimeout + self.maxParticipants = maxParticipants + self.metadata = metadata + self.minPlayoutDelay = minPlayoutDelay + self.maxPlayoutDelay = maxPlayoutDelay + self.syncStreams = syncStreams + self.agents = agents + } +} + +public struct RoomAgentDispatch: Encodable, Sendable, Equatable { + /// Name of the agent to dispatch + public let agentName: String? + + /// Metadata for the agent + public let metadata: String? + + enum CodingKeys: String, CodingKey { + case agentName = "agent_name" + case metadata + } + + public init( + agentName: String? = nil, + metadata: String? = nil + ) { + self.agentName = agentName + self.metadata = metadata + } +} diff --git a/Tests/LiveKitCoreTests/Token/TokenSourceTests.swift b/Tests/LiveKitCoreTests/Token/TokenSourceTests.swift new file mode 100644 index 000000000..3fa6cff0f --- /dev/null +++ b/Tests/LiveKitCoreTests/Token/TokenSourceTests.swift @@ -0,0 +1,266 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@testable import LiveKit +import LiveKitTestSupport + +class TokenSourceTests: LKTestCase { + actor MockValidJWTSource: TokenSourceConfigurable { + let serverURL = URL(string: "wss://test.livekit.io")! + let participantName: String + var callCount = 0 + + init(participantName: String = "test-participant") { + self.participantName = participantName + } + + func fetch(_ options: TokenRequestOptions) async throws -> TokenSourceResponse { + callCount += 1 + + let tokenGenerator = TokenGenerator( + apiKey: "test-api-key", + apiSecret: "test-api-secret", + identity: options.participantIdentity ?? "test-identity" + ) + tokenGenerator.name = options.participantName ?? participantName + tokenGenerator.videoGrant = LiveKitJWTPayload.VideoGrant(room: options.roomName ?? "test-room", roomJoin: true) + + let token = try tokenGenerator.sign() + + return TokenSourceResponse( + serverURL: serverURL, + participantToken: token + ) + } + } + + actor MockInvalidJWTSource: TokenSourceConfigurable { + let serverURL = URL(string: "wss://test.livekit.io")! + var callCount = 0 + + func fetch(_: TokenRequestOptions) async throws -> TokenSourceResponse { + callCount += 1 + + return TokenSourceResponse( + serverURL: serverURL, + participantToken: "invalid.jwt.token" + ) + } + } + + actor MockExpiredJWTSource: TokenSourceConfigurable { + let serverURL = URL(string: "wss://test.livekit.io")! + var callCount = 0 + + func fetch(_ options: TokenRequestOptions) async throws -> TokenSourceResponse { + callCount += 1 + + let tokenGenerator = TokenGenerator( + apiKey: "test-api-key", + apiSecret: "test-api-secret", + identity: options.participantIdentity ?? "test-identity", + ttl: -60 + ) + tokenGenerator.name = options.participantName ?? "test-participant" + tokenGenerator.videoGrant = LiveKitJWTPayload.VideoGrant(room: options.roomName ?? "test-room", roomJoin: true) + + let token = try tokenGenerator.sign() + + return TokenSourceResponse( + serverURL: serverURL, + participantToken: token + ) + } + } + + func testValidJWTCaching() async throws { + let mockSource = MockValidJWTSource(participantName: "alice") + let cachingSource = CachingTokenSource(mockSource) + + let request = TokenRequestOptions( + roomName: "test-room", + participantName: "alice", + participantIdentity: "alice-id" + ) + + let response1 = try await cachingSource.fetch(request) + let callCount1 = await mockSource.callCount + XCTAssertEqual(callCount1, 1) + XCTAssertEqual(response1.serverURL.absoluteString, "wss://test.livekit.io") + XCTAssertTrue(response1.hasValidToken(), "Generated token should be valid") + + let response2 = try await cachingSource.fetch(request) + let callCount2 = await mockSource.callCount + XCTAssertEqual(callCount2, 1) + XCTAssertEqual(response2.participantToken, response1.participantToken) + XCTAssertEqual(response2.serverURL, response1.serverURL) + + let differentRequest = TokenRequestOptions( + roomName: "different-room", + participantName: "alice", + participantIdentity: "alice-id" + ) + let response3 = try await cachingSource.fetch(differentRequest) + let callCount3 = await mockSource.callCount + XCTAssertEqual(callCount3, 2) + XCTAssertNotEqual(response3.participantToken, response1.participantToken) + + await cachingSource.invalidate() + _ = try await cachingSource.fetch(request) + let callCount4 = await mockSource.callCount + XCTAssertEqual(callCount4, 3) + } + + func testInvalidJWTHandling() async throws { + let mockInvalidSource = MockInvalidJWTSource() + let cachingSource = CachingTokenSource(mockInvalidSource) + + let request = TokenRequestOptions( + roomName: "test-room", + participantName: "bob", + participantIdentity: "bob-id" + ) + + let response1 = try await cachingSource.fetch(request) + let callCount1 = await mockInvalidSource.callCount + XCTAssertEqual(callCount1, 1) + XCTAssertFalse(response1.hasValidToken(), "Invalid token should not be considered valid") + + let response2 = try await cachingSource.fetch(request) + let callCount2 = await mockInvalidSource.callCount + XCTAssertEqual(callCount2, 2) + XCTAssertEqual(response2.participantToken, response1.participantToken) + + let mockExpiredSource = MockExpiredJWTSource() + let cachingSourceExpired = CachingTokenSource(mockExpiredSource) + + let response3 = try await cachingSourceExpired.fetch(request) + let expiredCallCount1 = await mockExpiredSource.callCount + XCTAssertEqual(expiredCallCount1, 1) + XCTAssertFalse(response3.hasValidToken(), "Expired token should not be considered valid") + + _ = try await cachingSourceExpired.fetch(request) + let expiredCallCount2 = await mockExpiredSource.callCount + XCTAssertEqual(expiredCallCount2, 2) + } + + func testCustomValidator() async throws { + let mockSource = MockValidJWTSource(participantName: "charlie") + + let customValidator: CachingTokenSource.Validator = { request, response in + request.participantName == "charlie" && response.hasValidToken() + } + + let cachingSource = CachingTokenSource(mockSource, validator: customValidator) + + let charlieRequest = TokenRequestOptions( + roomName: "test-room", + participantName: "charlie", + participantIdentity: "charlie-id" + ) + + let response1 = try await cachingSource.fetch(charlieRequest) + let callCount1 = await mockSource.callCount + XCTAssertEqual(callCount1, 1) + XCTAssertTrue(response1.hasValidToken()) + + let response2 = try await cachingSource.fetch(charlieRequest) + let callCount2 = await mockSource.callCount + XCTAssertEqual(callCount2, 1) + XCTAssertEqual(response2.participantToken, response1.participantToken) + + let aliceRequest = TokenRequestOptions( + roomName: "test-room", + participantName: "alice", + participantIdentity: "alice-id" + ) + + _ = try await cachingSource.fetch(aliceRequest) + let callCount3 = await mockSource.callCount + XCTAssertEqual(callCount3, 2) + + _ = try await cachingSource.fetch(aliceRequest) + let callCount4 = await mockSource.callCount + XCTAssertEqual(callCount4, 3) + + let tokenMockSource = MockValidJWTSource(participantName: "dave") + let tokenContentValidator: CachingTokenSource.Validator = { request, response in + request.roomName == "test-room" && response.hasValidToken() + } + + let tokenCachingSource = CachingTokenSource(tokenMockSource, validator: tokenContentValidator) + + let roomRequest = TokenRequestOptions( + roomName: "test-room", + participantName: "dave", + participantIdentity: "dave-id" + ) + + _ = try await tokenCachingSource.fetch(roomRequest) + let tokenCallCount1 = await tokenMockSource.callCount + XCTAssertEqual(tokenCallCount1, 1) + + _ = try await tokenCachingSource.fetch(roomRequest) + let tokenCallCount2 = await tokenMockSource.callCount + XCTAssertEqual(tokenCallCount2, 1) + + let differentRoomRequest = TokenRequestOptions( + roomName: "different-room", + participantName: "dave", + participantIdentity: "dave-id" + ) + + _ = try await tokenCachingSource.fetch(differentRoomRequest) + let tokenCallCount3 = await tokenMockSource.callCount + XCTAssertEqual(tokenCallCount3, 2) + + _ = try await tokenCachingSource.fetch(differentRoomRequest) + let tokenCallCount4 = await tokenMockSource.callCount + XCTAssertEqual(tokenCallCount4, 3) + } + + func testConcurrentAccess() async throws { + let mockSource = MockValidJWTSource(participantName: "concurrent-test") + let cachingSource = CachingTokenSource(mockSource) + + let request = TokenRequestOptions( + roomName: "concurrent-room", + participantName: "concurrent-user", + participantIdentity: "concurrent-id" + ) + + let initialResponse = try await cachingSource.fetch(request) + let initialCallCount = await mockSource.callCount + XCTAssertEqual(initialCallCount, 1) + + async let fetch1 = cachingSource.fetch(request) + async let fetch2 = cachingSource.fetch(request) + async let fetch3 = cachingSource.fetch(request) + + let responses = try await [fetch1, fetch2, fetch3] + + XCTAssertEqual(responses[0].participantToken, initialResponse.participantToken) + XCTAssertEqual(responses[1].participantToken, initialResponse.participantToken) + XCTAssertEqual(responses[2].participantToken, initialResponse.participantToken) + + XCTAssertEqual(responses[0].serverURL, initialResponse.serverURL) + XCTAssertEqual(responses[1].serverURL, initialResponse.serverURL) + XCTAssertEqual(responses[2].serverURL, initialResponse.serverURL) + + let finalCallCount = await mockSource.callCount + XCTAssertEqual(finalCallCount, 1) + } +} diff --git a/Tests/LiveKitTestSupport/Room.swift b/Tests/LiveKitTestSupport/Room.swift index 7ae906463..ab56ec53a 100644 --- a/Tests/LiveKitTestSupport/Room.swift +++ b/Tests/LiveKitTestSupport/Room.swift @@ -78,12 +78,12 @@ public extension LKTestCase { apiSecret: apiSecret, identity: identity) - tokenGenerator.videoGrant = VideoGrant(room: room, - roomJoin: true, - canPublish: canPublish, - canSubscribe: canSubscribe, - canPublishData: canPublishData, - canPublishSources: canPublishSources.map(String.init)) + tokenGenerator.videoGrant = LiveKitJWTPayload.VideoGrant(room: room, + roomJoin: true, + canPublish: canPublish, + canSubscribe: canSubscribe, + canPublishData: canPublishData, + canPublishSources: canPublishSources.map(String.init)) return try tokenGenerator.sign() } diff --git a/Tests/LiveKitTestSupport/TokenGenerator.swift b/Tests/LiveKitTestSupport/TokenGenerator.swift index 98525f795..bfeac2f80 100644 --- a/Tests/LiveKitTestSupport/TokenGenerator.swift +++ b/Tests/LiveKitTestSupport/TokenGenerator.swift @@ -14,85 +14,10 @@ * limitations under the License. */ -import Foundation import JWTKit - -public struct VideoGrant: Codable, Equatable { - /** name of the room, must be set for admin or join permissions */ - public let room: String? - /** permission to create a room */ - public let roomCreate: Bool? - /** permission to join a room as a participant, room must be set */ - public let roomJoin: Bool? - /** permission to list rooms */ - public let roomList: Bool? - /** permission to start a recording */ - public let roomRecord: Bool? - /** permission to control a specific room, room must be set */ - public let roomAdmin: Bool? - - /** - * allow participant to publish. If neither canPublish or canSubscribe is set, - * both publish and subscribe are enabled - */ - public let canPublish: Bool? - /** allow participant to subscribe to other tracks */ - public let canSubscribe: Bool? - /** - * allow participants to publish data, defaults to true if not set - */ - public let canPublishData: Bool? - /** allowed sources for publishing */ - public let canPublishSources: [String]? // String as returned in the JWT - /** participant isn't visible to others */ - public let hidden: Bool? - /** participant is recording the room, when set, allows room to indicate it's being recorded */ - public let recorder: Bool? - - public init(room: String? = nil, - roomCreate: Bool? = nil, - roomJoin: Bool? = nil, - roomList: Bool? = nil, - roomRecord: Bool? = nil, - roomAdmin: Bool? = nil, - canPublish: Bool? = nil, - canSubscribe: Bool? = nil, - canPublishData: Bool? = nil, - canPublishSources: [String]? = nil, - hidden: Bool? = nil, - recorder: Bool? = nil) - { - self.room = room - self.roomCreate = roomCreate - self.roomJoin = roomJoin - self.roomList = roomList - self.roomRecord = roomRecord - self.roomAdmin = roomAdmin - self.canPublish = canPublish - self.canSubscribe = canSubscribe - self.canPublishData = canPublishData - self.canPublishSources = canPublishSources - self.hidden = hidden - self.recorder = recorder - } -} +@testable import LiveKit public class TokenGenerator { - private struct Payload: JWTPayload, Equatable { - let exp: ExpirationClaim - let iss: IssuerClaim - let nbf: NotBeforeClaim - let sub: SubjectClaim - - let name: String? - let metadata: String? - let video: VideoGrant? - - func verify(using _: JWTSigner) throws { - fatalError("not implemented") - } - } - // 30 mins public static let defaultTTL: TimeInterval = 30 * 60 @@ -104,7 +29,7 @@ public class TokenGenerator { public var ttl: TimeInterval public var name: String? public var metadata: String? - public var videoGrant: VideoGrant? + public var videoGrant: LiveKitJWTPayload.VideoGrant? // MARK: - Private @@ -127,13 +52,13 @@ public class TokenGenerator { let n = Date().timeIntervalSince1970 - let p = Payload(exp: .init(value: Date(timeIntervalSince1970: floor(n + ttl))), - iss: .init(stringLiteral: apiKey), - nbf: .init(value: Date(timeIntervalSince1970: floor(n))), - sub: .init(stringLiteral: identity), - name: name, - metadata: metadata, - video: videoGrant) + let p = LiveKitJWTPayload(exp: .init(value: Date(timeIntervalSince1970: floor(n + ttl))), + iss: .init(stringLiteral: apiKey), + nbf: .init(value: Date(timeIntervalSince1970: floor(n))), + sub: .init(stringLiteral: identity), + name: name, + metadata: metadata, + video: videoGrant) return try signers.sign(p) }