From 26a4490e92b56408a5841d638568d345f68786b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Tue, 16 Sep 2025 10:48:02 +0200 Subject: [PATCH 01/32] Basic providers with cache --- .../LiveKit/Auth/ConnectionCredentials.swift | 160 ++++++++++++++++++ Sources/LiveKit/Core/Room.swift | 8 + 2 files changed, 168 insertions(+) create mode 100644 Sources/LiveKit/Auth/ConnectionCredentials.swift diff --git a/Sources/LiveKit/Auth/ConnectionCredentials.swift b/Sources/LiveKit/Auth/ConnectionCredentials.swift new file mode 100644 index 000000000..6853ae044 --- /dev/null +++ b/Sources/LiveKit/Auth/ConnectionCredentials.swift @@ -0,0 +1,160 @@ +/* + * 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 enum ConnectionCredentials { + public struct Request: Encodable, Equatable, Sendable { + let roomName: String? = nil + let participantName: String? = nil + let participantIdentity: String? = nil + let participantMetadata: String? = nil + let participantAttributes: [String: String]? = nil +// let roomConfiguration: RoomConfiguration? = nil + } + + public struct Response: Decodable, Sendable { + let serverUrl: URL + let participantToken: String + } + + public typealias Literal = Response +} + +// MARK: - Provider + +public protocol CredentialsProvider: Sendable { + func fetch(_ request: ConnectionCredentials.Request) async throws -> ConnectionCredentials.Response +} + +// MARK: - Implementation + +extension ConnectionCredentials.Literal: CredentialsProvider { + public func fetch(_: ConnectionCredentials.Request) async throws -> ConnectionCredentials.Response { + self + } +} + +public struct SandboxTokenServer: CredentialsProvider, Loggable { + private static let baseURL = URL(string: "https://cloud-api.livekit.io")! + + public struct Options: Sendable { + let id: String + let baseURL: URL? = nil + } + + private let options: Options + + public init(options: Options) { + self.options = options + } + + public init(id: String) { + options = .init(id: id) + } + + public func fetch(_ request: ConnectionCredentials.Request) async throws -> ConnectionCredentials.Response { + log("Using sandbox token server is not applicable for production environemnt", .info) + + let baseURL = options.baseURL ?? Self.baseURL + var urlRequest = URLRequest(url: baseURL.appendingPathComponent("api/sandbox/connection-details")) + + urlRequest.httpMethod = "POST" + urlRequest.addValue(options.id.trimmingCharacters(in: CharacterSet(charactersIn: "\"")), forHTTPHeaderField: "X-Sandbox-ID") + urlRequest.httpBody = try JSONEncoder().encode(request) + + 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 sandbox token server, no response") + } + + guard (200 ... 299).contains(httpResponse.statusCode) else { + throw LiveKitError(.network, message: "Error generating token from sandbox token server, received \(httpResponse)") + } + + return try JSONDecoder().decode(ConnectionCredentials.Response.self, from: data) + } +} + +// MARK: - Cache + +public actor CachingCredentialsProvider: CredentialsProvider, Loggable { + private let provider: CredentialsProvider + private let validator: (ConnectionCredentials.Request, ConnectionCredentials.Response) -> Bool + + private var cached: (ConnectionCredentials.Request, ConnectionCredentials.Response)? + + public init(_ provider: CredentialsProvider, validator: @escaping (ConnectionCredentials.Request, ConnectionCredentials.Response) -> Bool = { _, res in res.hasValidToken() }) { + self.provider = provider + self.validator = validator + } + + public func fetch(_ request: ConnectionCredentials.Request) async throws -> ConnectionCredentials.Response { + if let (cachedRequest, cachedResponse) = cached, cachedRequest == request, validator(cachedRequest, cachedResponse) { + log("Using cached credentials", .debug) + return cachedResponse + } + + let response = try await provider.fetch(request) + cached = (request, response) + return response + } + + public func invalidate() { + cached = nil + } +} + +// MARK: - Validation + +public extension ConnectionCredentials.Response { + func hasValidToken(withTolerance tolerance: TimeInterval = 60) -> Bool { + let parts = participantToken.components(separatedBy: ".") + guard parts.count == 3 else { + return false + } + + let payloadData = parts[1] + + struct JWTPayload: Decodable { + let exp: Double + } + + guard let payloadJSON = payloadData.base64URLDecode(), + let payload = try? JSONDecoder().decode(JWTPayload.self, from: payloadJSON) + else { + return false + } + + let now = Date().timeIntervalSince1970 + return payload.exp > now - tolerance + } +} + +private extension String { + func base64URLDecode() -> Data? { + var base64 = self + base64 = base64.replacingOccurrences(of: "-", with: "+") + base64 = base64.replacingOccurrences(of: "_", with: "/") + + while base64.count % 4 != 0 { + base64.append("=") + } + + return Data(base64Encoded: base64) + } +} diff --git a/Sources/LiveKit/Core/Room.swift b/Sources/LiveKit/Core/Room.swift index ba1b4de46..6321ffb1a 100644 --- a/Sources/LiveKit/Core/Room.swift +++ b/Sources/LiveKit/Core/Room.swift @@ -422,6 +422,14 @@ public class Room: NSObject, @unchecked Sendable, ObservableObject, Loggable { log("Connected to \(String(describing: self))", .info) } + public func connect(credentialsProvider: CredentialsProvider, + connectOptions: ConnectOptions? = nil, + roomOptions: RoomOptions? = nil) async throws + { + let credentials = try await credentialsProvider.fetch(.init()) + try await connect(url: credentials.serverUrl.absoluteString, token: credentials.participantToken, connectOptions: connectOptions, roomOptions: roomOptions) + } + @objc public func disconnect() async { let shouldDisconnect = _state.mutate { From b0cf3a7743dd9a07fd74208358f60b99bbf92d6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Tue, 16 Sep 2025 11:04:31 +0200 Subject: [PATCH 02/32] Split token server --- .../LiveKit/Auth/ConnectionCredentials.swift | 52 ++++++++++--------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/Sources/LiveKit/Auth/ConnectionCredentials.swift b/Sources/LiveKit/Auth/ConnectionCredentials.swift index 6853ae044..f4bb81919 100644 --- a/Sources/LiveKit/Auth/ConnectionCredentials.swift +++ b/Sources/LiveKit/Auth/ConnectionCredentials.swift @@ -40,40 +40,31 @@ public protocol CredentialsProvider: Sendable { func fetch(_ request: ConnectionCredentials.Request) async throws -> ConnectionCredentials.Response } -// MARK: - Implementation - extension ConnectionCredentials.Literal: CredentialsProvider { public func fetch(_: ConnectionCredentials.Request) async throws -> ConnectionCredentials.Response { self } } -public struct SandboxTokenServer: CredentialsProvider, Loggable { - private static let baseURL = URL(string: "https://cloud-api.livekit.io")! - - public struct Options: Sendable { - let id: String - let baseURL: URL? = nil - } +// MARK: - Token Server - private let options: Options - - public init(options: Options) { - self.options = options - } - - public init(id: String) { - options = .init(id: id) - } +public protocol TokenServer: CredentialsProvider { + var url: URL { get } + var method: String { get } + var headers: [String: String] { get } +} - public func fetch(_ request: ConnectionCredentials.Request) async throws -> ConnectionCredentials.Response { - log("Using sandbox token server is not applicable for production environemnt", .info) +public extension TokenServer { + var method: String { "POST" } + var headers: [String: String] { [:] } - let baseURL = options.baseURL ?? Self.baseURL - var urlRequest = URLRequest(url: baseURL.appendingPathComponent("api/sandbox/connection-details")) + func fetch(_ request: ConnectionCredentials.Request) async throws -> ConnectionCredentials.Response { + var urlRequest = URLRequest(url: url) - urlRequest.httpMethod = "POST" - urlRequest.addValue(options.id.trimmingCharacters(in: CharacterSet(charactersIn: "\"")), forHTTPHeaderField: "X-Sandbox-ID") + urlRequest.httpMethod = method + for (key, value) in headers { + urlRequest.addValue(value, forHTTPHeaderField: key) + } urlRequest.httpBody = try JSONEncoder().encode(request) let (data, response) = try await URLSession.shared.data(for: urlRequest) @@ -90,6 +81,19 @@ public struct SandboxTokenServer: CredentialsProvider, Loggable { } } +public struct SandboxTokenServer: TokenServer { + public let url = URL(string: "https://cloud-api.livekit.io/api/sandbox/connection-details")! + public var headers: [String: String] { + ["X-Sandbox-ID": id.trimmingCharacters(in: CharacterSet(charactersIn: "\""))] + } + + public let id: String + + public init(id: String) { + self.id = id + } +} + // MARK: - Cache public actor CachingCredentialsProvider: CredentialsProvider, Loggable { From d88c2fb614156c32efb89d20910cd78de9d17183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Tue, 16 Sep 2025 11:28:37 +0200 Subject: [PATCH 03/32] Pass options --- .../LiveKit/Auth/ConnectionCredentials.swift | 26 ++++++++++++++----- Sources/LiveKit/Core/Room.swift | 3 ++- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/Sources/LiveKit/Auth/ConnectionCredentials.swift b/Sources/LiveKit/Auth/ConnectionCredentials.swift index f4bb81919..e63dd74b1 100644 --- a/Sources/LiveKit/Auth/ConnectionCredentials.swift +++ b/Sources/LiveKit/Auth/ConnectionCredentials.swift @@ -18,19 +18,33 @@ import Foundation public enum ConnectionCredentials { public struct Request: Encodable, Equatable, Sendable { - let roomName: String? = nil - let participantName: String? = nil - let participantIdentity: String? = nil - let participantMetadata: String? = nil - let participantAttributes: [String: String]? = nil -// let roomConfiguration: RoomConfiguration? = nil + let roomName: String? + let participantName: String? + let participantIdentity: String? + let participantMetadata: String? + let participantAttributes: [String: String]? +// let roomConfiguration: RoomConfiguration? + + public init(roomName: String? = nil, participantName: String? = nil, participantIdentity: String? = nil, participantMetadata: String? = nil, participantAttributes: [String: String]? = nil) { + self.roomName = roomName + self.participantName = participantName + self.participantIdentity = participantIdentity + self.participantMetadata = participantMetadata + self.participantAttributes = participantAttributes + } } public struct Response: Decodable, Sendable { let serverUrl: URL let participantToken: String + + public init(serverUrl: URL, participantToken: String) { + self.serverUrl = serverUrl + self.participantToken = participantToken + } } + public typealias Options = Request public typealias Literal = Response } diff --git a/Sources/LiveKit/Core/Room.swift b/Sources/LiveKit/Core/Room.swift index 6321ffb1a..354fcaf08 100644 --- a/Sources/LiveKit/Core/Room.swift +++ b/Sources/LiveKit/Core/Room.swift @@ -423,10 +423,11 @@ public class Room: NSObject, @unchecked Sendable, ObservableObject, Loggable { } public func connect(credentialsProvider: CredentialsProvider, + credentialsOptions: ConnectionCredentials.Options = .init(), connectOptions: ConnectOptions? = nil, roomOptions: RoomOptions? = nil) async throws { - let credentials = try await credentialsProvider.fetch(.init()) + let credentials = try await credentialsProvider.fetch(credentialsOptions) try await connect(url: credentials.serverUrl.absoluteString, token: credentials.participantToken, connectOptions: connectOptions, roomOptions: roomOptions) } From 5aacd73cbbd316117ff94c808d0fbc8cb875ddcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Tue, 16 Sep 2025 11:58:41 +0200 Subject: [PATCH 04/32] Expose RoomConfiguration (without pb) --- .../LiveKit/Auth/ConnectionCredentials.swift | 22 ++-- Sources/LiveKit/Types/RoomConfiguration.swift | 102 ++++++++++++++++++ 2 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 Sources/LiveKit/Types/RoomConfiguration.swift diff --git a/Sources/LiveKit/Auth/ConnectionCredentials.swift b/Sources/LiveKit/Auth/ConnectionCredentials.swift index e63dd74b1..1fb84b0fc 100644 --- a/Sources/LiveKit/Auth/ConnectionCredentials.swift +++ b/Sources/LiveKit/Auth/ConnectionCredentials.swift @@ -17,20 +17,28 @@ import Foundation public enum ConnectionCredentials { - public struct Request: Encodable, Equatable, Sendable { + public struct Request: Encodable, Sendable, Equatable { let roomName: String? let participantName: String? let participantIdentity: String? let participantMetadata: String? let participantAttributes: [String: String]? -// let roomConfiguration: RoomConfiguration? - - public init(roomName: String? = nil, participantName: String? = nil, participantIdentity: String? = nil, participantMetadata: String? = nil, participantAttributes: [String: String]? = nil) { + let roomConfiguration: RoomConfiguration? + + public init( + roomName: String? = nil, + participantName: String? = nil, + participantIdentity: String? = nil, + participantMetadata: String? = nil, + participantAttributes: [String: String]? = nil, + roomConfiguration: RoomConfiguration? = nil + ) { self.roomName = roomName self.participantName = participantName self.participantIdentity = participantIdentity self.participantMetadata = participantMetadata self.participantAttributes = participantAttributes + self.roomConfiguration = roomConfiguration } } @@ -111,12 +119,14 @@ public struct SandboxTokenServer: TokenServer { // MARK: - Cache public actor CachingCredentialsProvider: CredentialsProvider, Loggable { + public typealias Validator = (ConnectionCredentials.Request, ConnectionCredentials.Response) -> Bool + private let provider: CredentialsProvider - private let validator: (ConnectionCredentials.Request, ConnectionCredentials.Response) -> Bool + private let validator: Validator private var cached: (ConnectionCredentials.Request, ConnectionCredentials.Response)? - public init(_ provider: CredentialsProvider, validator: @escaping (ConnectionCredentials.Request, ConnectionCredentials.Response) -> Bool = { _, res in res.hasValidToken() }) { + public init(_ provider: CredentialsProvider, validator: @escaping Validator = { _, res in res.hasValidToken() }) { self.provider = provider self.validator = validator } diff --git a/Sources/LiveKit/Types/RoomConfiguration.swift b/Sources/LiveKit/Types/RoomConfiguration.swift new file mode 100644 index 000000000..6e54a768b --- /dev/null +++ b/Sources/LiveKit/Types/RoomConfiguration.swift @@ -0,0 +1,102 @@ +/* + * 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? + + /// 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 + } +} From b7e999e2b3abecaff8efc84e4ad24edffffe0d7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Tue, 16 Sep 2025 13:10:16 +0200 Subject: [PATCH 05/32] Add some tests --- .../LiveKit/Auth/ConnectionCredentials.swift | 10 +- .../ConnectionCredentialsTests.swift | 267 ++++++++++++++++++ 2 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 Tests/LiveKitTests/ConnectionCredentialsTests.swift diff --git a/Sources/LiveKit/Auth/ConnectionCredentials.swift b/Sources/LiveKit/Auth/ConnectionCredentials.swift index 1fb84b0fc..62df4703a 100644 --- a/Sources/LiveKit/Auth/ConnectionCredentials.swift +++ b/Sources/LiveKit/Auth/ConnectionCredentials.swift @@ -119,12 +119,13 @@ public struct SandboxTokenServer: TokenServer { // MARK: - Cache public actor CachingCredentialsProvider: CredentialsProvider, Loggable { + public typealias Cached = (ConnectionCredentials.Request, ConnectionCredentials.Response) public typealias Validator = (ConnectionCredentials.Request, ConnectionCredentials.Response) -> Bool private let provider: CredentialsProvider private let validator: Validator - private var cached: (ConnectionCredentials.Request, ConnectionCredentials.Response)? + private var cached: Cached? public init(_ provider: CredentialsProvider, validator: @escaping Validator = { _, res in res.hasValidToken() }) { self.provider = provider @@ -159,22 +160,23 @@ public extension ConnectionCredentials.Response { let payloadData = parts[1] struct JWTPayload: Decodable { + let nbf: Double let exp: Double } - guard let payloadJSON = payloadData.base64URLDecode(), + guard let payloadJSON = payloadData.base64Decode(), let payload = try? JSONDecoder().decode(JWTPayload.self, from: payloadJSON) else { return false } let now = Date().timeIntervalSince1970 - return payload.exp > now - tolerance + return payload.nbf <= now && payload.exp > now - tolerance } } private extension String { - func base64URLDecode() -> Data? { + func base64Decode() -> Data? { var base64 = self base64 = base64.replacingOccurrences(of: "-", with: "+") base64 = base64.replacingOccurrences(of: "_", with: "/") diff --git a/Tests/LiveKitTests/ConnectionCredentialsTests.swift b/Tests/LiveKitTests/ConnectionCredentialsTests.swift new file mode 100644 index 000000000..cadf39ee6 --- /dev/null +++ b/Tests/LiveKitTests/ConnectionCredentialsTests.swift @@ -0,0 +1,267 @@ +/* + * 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 +@testable import LiveKit +import XCTest + +class ConnectionCredentialsTests: LKTestCase { + actor MockValidJWTProvider: CredentialsProvider { + let serverUrl = URL(string: "wss://test.livekit.io")! + let participantName: String + var callCount = 0 + + init(participantName: String = "test-participant") { + self.participantName = participantName + } + + func fetch(_ request: ConnectionCredentials.Request) async throws -> ConnectionCredentials.Response { + callCount += 1 + + let tokenGenerator = TokenGenerator( + apiKey: "test-api-key", + apiSecret: "test-api-secret", + identity: request.participantIdentity ?? "test-identity" + ) + tokenGenerator.name = request.participantName ?? participantName + tokenGenerator.videoGrant = VideoGrant(room: request.roomName ?? "test-room", roomJoin: true) + + let token = try tokenGenerator.sign() + + return ConnectionCredentials.Response( + serverUrl: serverUrl, + participantToken: token + ) + } + } + + actor MockInvalidJWTProvider: CredentialsProvider { + let serverUrl = URL(string: "wss://test.livekit.io")! + var callCount = 0 + + func fetch(_: ConnectionCredentials.Request) async throws -> ConnectionCredentials.Response { + callCount += 1 + + return ConnectionCredentials.Response( + serverUrl: serverUrl, + participantToken: "invalid.jwt.token" + ) + } + } + + actor MockExpiredJWTProvider: CredentialsProvider { + let serverUrl = URL(string: "wss://test.livekit.io")! + var callCount = 0 + + func fetch(_ request: ConnectionCredentials.Request) async throws -> ConnectionCredentials.Response { + callCount += 1 + + let tokenGenerator = TokenGenerator( + apiKey: "test-api-key", + apiSecret: "test-api-secret", + identity: request.participantIdentity ?? "test-identity", + ttl: -60 + ) + tokenGenerator.name = request.participantName ?? "test-participant" + tokenGenerator.videoGrant = VideoGrant(room: request.roomName ?? "test-room", roomJoin: true) + + let token = try tokenGenerator.sign() + + return ConnectionCredentials.Response( + serverUrl: serverUrl, + participantToken: token + ) + } + } + + func testValidJWTCaching() async throws { + let mockProvider = MockValidJWTProvider(participantName: "alice") + let cachingProvider = CachingCredentialsProvider(mockProvider) + + let request = ConnectionCredentials.Request( + roomName: "test-room", + participantName: "alice", + participantIdentity: "alice-id" + ) + + let response1 = try await cachingProvider.fetch(request) + let callCount1 = await mockProvider.callCount + XCTAssertEqual(callCount1, 1) + XCTAssertEqual(response1.serverUrl.absoluteString, "wss://test.livekit.io") + XCTAssertTrue(response1.hasValidToken(), "Generated token should be valid") + + let response2 = try await cachingProvider.fetch(request) + let callCount2 = await mockProvider.callCount + XCTAssertEqual(callCount2, 1) + XCTAssertEqual(response2.participantToken, response1.participantToken) + XCTAssertEqual(response2.serverUrl, response1.serverUrl) + + let differentRequest = ConnectionCredentials.Request( + roomName: "different-room", + participantName: "alice", + participantIdentity: "alice-id" + ) + let response3 = try await cachingProvider.fetch(differentRequest) + let callCount3 = await mockProvider.callCount + XCTAssertEqual(callCount3, 2) + XCTAssertNotEqual(response3.participantToken, response1.participantToken) + + await cachingProvider.invalidate() + _ = try await cachingProvider.fetch(request) + let callCount4 = await mockProvider.callCount + XCTAssertEqual(callCount4, 3) + } + + func testInvalidJWTHandling() async throws { + let mockInvalidProvider = MockInvalidJWTProvider() + let cachingProvider = CachingCredentialsProvider(mockInvalidProvider) + + let request = ConnectionCredentials.Request( + roomName: "test-room", + participantName: "bob", + participantIdentity: "bob-id" + ) + + let response1 = try await cachingProvider.fetch(request) + let callCount1 = await mockInvalidProvider.callCount + XCTAssertEqual(callCount1, 1) + XCTAssertFalse(response1.hasValidToken(), "Invalid token should not be considered valid") + + let response2 = try await cachingProvider.fetch(request) + let callCount2 = await mockInvalidProvider.callCount + XCTAssertEqual(callCount2, 2) + XCTAssertEqual(response2.participantToken, response1.participantToken) + + let mockExpiredProvider = MockExpiredJWTProvider() + let cachingProviderExpired = CachingCredentialsProvider(mockExpiredProvider) + + let response3 = try await cachingProviderExpired.fetch(request) + let expiredCallCount1 = await mockExpiredProvider.callCount + XCTAssertEqual(expiredCallCount1, 1) + XCTAssertFalse(response3.hasValidToken(), "Expired token should not be considered valid") + + _ = try await cachingProviderExpired.fetch(request) + let expiredCallCount2 = await mockExpiredProvider.callCount + XCTAssertEqual(expiredCallCount2, 2) + } + + func testCustomValidator() async throws { + let mockProvider = MockValidJWTProvider(participantName: "charlie") + + let customValidator: CachingCredentialsProvider.Validator = { request, response in + request.participantName == "charlie" && response.hasValidToken() + } + + let cachingProvider = CachingCredentialsProvider(mockProvider, validator: customValidator) + + let charlieRequest = ConnectionCredentials.Request( + roomName: "test-room", + participantName: "charlie", + participantIdentity: "charlie-id" + ) + + let response1 = try await cachingProvider.fetch(charlieRequest) + let callCount1 = await mockProvider.callCount + XCTAssertEqual(callCount1, 1) + XCTAssertTrue(response1.hasValidToken()) + + let response2 = try await cachingProvider.fetch(charlieRequest) + let callCount2 = await mockProvider.callCount + XCTAssertEqual(callCount2, 1) + XCTAssertEqual(response2.participantToken, response1.participantToken) + + let aliceRequest = ConnectionCredentials.Request( + roomName: "test-room", + participantName: "alice", + participantIdentity: "alice-id" + ) + + _ = try await cachingProvider.fetch(aliceRequest) + let callCount3 = await mockProvider.callCount + XCTAssertEqual(callCount3, 2) + + _ = try await cachingProvider.fetch(aliceRequest) + let callCount4 = await mockProvider.callCount + XCTAssertEqual(callCount4, 3) + + let tokenMockProvider = MockValidJWTProvider(participantName: "dave") + let tokenContentValidator: CachingCredentialsProvider.Validator = { request, response in + request.roomName == "test-room" && response.hasValidToken() + } + + let tokenCachingProvider = CachingCredentialsProvider(tokenMockProvider, validator: tokenContentValidator) + + let roomRequest = ConnectionCredentials.Request( + roomName: "test-room", + participantName: "dave", + participantIdentity: "dave-id" + ) + + _ = try await tokenCachingProvider.fetch(roomRequest) + let tokenCallCount1 = await tokenMockProvider.callCount + XCTAssertEqual(tokenCallCount1, 1) + + _ = try await tokenCachingProvider.fetch(roomRequest) + let tokenCallCount2 = await tokenMockProvider.callCount + XCTAssertEqual(tokenCallCount2, 1) + + let differentRoomRequest = ConnectionCredentials.Request( + roomName: "different-room", + participantName: "dave", + participantIdentity: "dave-id" + ) + + _ = try await tokenCachingProvider.fetch(differentRoomRequest) + let tokenCallCount3 = await tokenMockProvider.callCount + XCTAssertEqual(tokenCallCount3, 2) + + _ = try await tokenCachingProvider.fetch(differentRoomRequest) + let tokenCallCount4 = await tokenMockProvider.callCount + XCTAssertEqual(tokenCallCount4, 3) + } + + func testConcurrentAccess() async throws { + let mockProvider = MockValidJWTProvider(participantName: "concurrent-test") + let cachingProvider = CachingCredentialsProvider(mockProvider) + + let request = ConnectionCredentials.Request( + roomName: "concurrent-room", + participantName: "concurrent-user", + participantIdentity: "concurrent-id" + ) + + let initialResponse = try await cachingProvider.fetch(request) + let initialCallCount = await mockProvider.callCount + XCTAssertEqual(initialCallCount, 1) + + async let fetch1 = cachingProvider.fetch(request) + async let fetch2 = cachingProvider.fetch(request) + async let fetch3 = cachingProvider.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 mockProvider.callCount + XCTAssertEqual(finalCallCount, 1) + } +} From e311976415171dde0cb66bffcfdada61016dc24b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Tue, 16 Sep 2025 13:41:55 +0200 Subject: [PATCH 06/32] Cmts --- .../LiveKit/Auth/ConnectionCredentials.swift | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/Sources/LiveKit/Auth/ConnectionCredentials.swift b/Sources/LiveKit/Auth/ConnectionCredentials.swift index 62df4703a..00566fbeb 100644 --- a/Sources/LiveKit/Auth/ConnectionCredentials.swift +++ b/Sources/LiveKit/Auth/ConnectionCredentials.swift @@ -16,13 +16,23 @@ import Foundation +/// `ConnectionCredentials` represent the credentials needed for connecting to a new Room. +/// - SeeAlso: [LiveKit's Authentication Documentation](https://docs.livekit.io/home/get-started/authentication/) for more information. public enum ConnectionCredentials { + /// Request parameters for generating connection credentials. public struct Request: Encodable, Sendable, Equatable { + /// The name of the room being requested when generating credentials. let roomName: String? + /// The name of the participant being requested for this client when generating credentials. let participantName: String? + /// The identity of the participant being requested for this client when generating credentials. let participantIdentity: String? + /// Any participant metadata being included along with the credentials generation operation. let participantMetadata: String? + /// Any participant attributes being included along with the credentials generation operation. let participantAttributes: [String: String]? + /// A `RoomConfiguration` object can be passed to request extra parameters should be included when generating connection credentials - dispatching agents, etc. + /// - SeeAlso: [Room Configuration Documentation](https://docs.livekit.io/home/get-started/authentication/#room-configuration) for more info. let roomConfiguration: RoomConfiguration? public init( @@ -42,8 +52,11 @@ public enum ConnectionCredentials { } } + /// Response containing the credentials needed to connect to a room. public struct Response: Decodable, Sendable { + /// The WebSocket URL for the LiveKit server. let serverUrl: URL + /// The JWT token containing participant permissions and metadata. let participantToken: String public init(serverUrl: URL, participantToken: String) { @@ -58,10 +71,14 @@ public enum ConnectionCredentials { // MARK: - Provider +/// Protocol for types that can provide connection credentials. +/// Implement this protocol to create custom credential providers (e.g., fetching from your backend API). public protocol CredentialsProvider: Sendable { func fetch(_ request: ConnectionCredentials.Request) async throws -> ConnectionCredentials.Response } +/// `ConnectionCredentials.Literal` contains a single set of credentials, hard-coded or acquired from a static source. +/// - Note: It does not support refresing credentials. extension ConnectionCredentials.Literal: CredentialsProvider { public func fetch(_: ConnectionCredentials.Request) async throws -> ConnectionCredentials.Response { self @@ -70,9 +87,15 @@ extension ConnectionCredentials.Literal: CredentialsProvider { // MARK: - Token Server +/// 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. +/// - Note: The response is expected to be a `ConnectionCredentials.Response` object. public protocol TokenServer: CredentialsProvider { + /// The URL endpoint for token generation. var url: URL { get } + /// The HTTP method to use (defaults to "POST"). var method: String { get } + /// Additional HTTP headers to include with the request. var headers: [String: String] { get } } @@ -103,14 +126,19 @@ public extension TokenServer { } } +/// `SandboxTokenServer` queries LiveKit Sandbox token server for credentials, +/// which supports quick prototyping/getting started types of use cases. +/// - Warning: This token provider is **INSECURE** and should **NOT** be used in production. public struct SandboxTokenServer: TokenServer { public let url = URL(string: "https://cloud-api.livekit.io/api/sandbox/connection-details")! public var headers: [String: String] { ["X-Sandbox-ID": id.trimmingCharacters(in: CharacterSet(charactersIn: "\""))] } + /// The sandbox ID provided by LiveKit Cloud. public let id: String + /// Initialize with a sandbox ID from LiveKit Cloud. public init(id: String) { self.id = id } @@ -118,8 +146,11 @@ public struct SandboxTokenServer: TokenServer { // MARK: - Cache +/// `CachingCredentialsProvider` handles in-memory caching of credentials from any other `CredentialsProvider`. public actor CachingCredentialsProvider: CredentialsProvider, Loggable { + /// A tuple containing the request and response that were cached. public typealias Cached = (ConnectionCredentials.Request, ConnectionCredentials.Response) + /// A closure that validates whether cached credentials are still valid. public typealias Validator = (ConnectionCredentials.Request, ConnectionCredentials.Response) -> Bool private let provider: CredentialsProvider @@ -127,6 +158,10 @@ public actor CachingCredentialsProvider: CredentialsProvider, Loggable { private var cached: Cached? + /// Initialize a caching wrapper around any credentials provider. + /// - Parameters: + /// - provider: The underlying credentials provider to wrap + /// - validator: A closure to determine if cached credentials are still valid (defaults to JWT expiration check) public init(_ provider: CredentialsProvider, validator: @escaping Validator = { _, res in res.hasValidToken() }) { self.provider = provider self.validator = validator @@ -143,6 +178,7 @@ public actor CachingCredentialsProvider: CredentialsProvider, Loggable { return response } + /// Invalidate the cached credentials, forcing a fresh fetch on the next request. public func invalidate() { cached = nil } From 894d24ed0bfd8205015ea03d22607a347934b2cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Tue, 16 Sep 2025 13:54:51 +0200 Subject: [PATCH 07/32] Extract storage --- .../LiveKit/Auth/ConnectionCredentials.swift | 57 ++++++++++++++++--- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/Sources/LiveKit/Auth/ConnectionCredentials.swift b/Sources/LiveKit/Auth/ConnectionCredentials.swift index 00566fbeb..083373a9f 100644 --- a/Sources/LiveKit/Auth/ConnectionCredentials.swift +++ b/Sources/LiveKit/Auth/ConnectionCredentials.swift @@ -146,7 +146,7 @@ public struct SandboxTokenServer: TokenServer { // MARK: - Cache -/// `CachingCredentialsProvider` handles in-memory caching of credentials from any other `CredentialsProvider`. +/// `CachingCredentialsProvider` handles caching of credentials from any other `CredentialsProvider` using configurable storage. public actor CachingCredentialsProvider: CredentialsProvider, Loggable { /// A tuple containing the request and response that were cached. public typealias Cached = (ConnectionCredentials.Request, ConnectionCredentials.Response) @@ -155,31 +155,74 @@ public actor CachingCredentialsProvider: CredentialsProvider, Loggable { private let provider: CredentialsProvider private let validator: Validator - - private var cached: Cached? + private let storage: CredentialsStorage /// Initialize a caching wrapper around any credentials provider. /// - Parameters: /// - provider: The underlying credentials provider to wrap + /// - storage: The storage implementation to use for caching (defaults to in-memory storage) /// - validator: A closure to determine if cached credentials are still valid (defaults to JWT expiration check) - public init(_ provider: CredentialsProvider, validator: @escaping Validator = { _, res in res.hasValidToken() }) { + public init( + _ provider: CredentialsProvider, + storage: CredentialsStorage = InMemoryCredentialsStorage(), + validator: @escaping Validator = { _, res in res.hasValidToken() } + ) { self.provider = provider + self.storage = storage self.validator = validator } public func fetch(_ request: ConnectionCredentials.Request) async throws -> ConnectionCredentials.Response { - if let (cachedRequest, cachedResponse) = cached, cachedRequest == request, validator(cachedRequest, cachedResponse) { + if let (cachedRequest, cachedResponse) = await storage.retrieve(), + cachedRequest == request, + validator(cachedRequest, cachedResponse) + { log("Using cached credentials", .debug) return cachedResponse } let response = try await provider.fetch(request) - cached = (request, response) + try await storage.store((request, response)) return response } /// Invalidate the cached credentials, forcing a fresh fetch on the next request. - public func invalidate() { + public func invalidate() async { + await storage.clear() + } +} + +// MARK: - Storage + +/// Protocol for abstract storage that can persist and retrieve a single cached credential pair. +/// Implement this protocol to create custom storage implementations e.g. for Keychain. +public protocol CredentialsStorage: Sendable { + /// Store credentials in the storage (replaces any existing credentials) + func store(_ credentials: CachingCredentialsProvider.Cached) async throws + + /// Retrieve the cached credentials + /// - Returns: The cached credentials if found, nil otherwise + func retrieve() async -> CachingCredentialsProvider.Cached? + + /// Clear the stored credentials + func clear() async +} + +/// Simple in-memory storage implementation +public actor InMemoryCredentialsStorage: CredentialsStorage { + private var cached: CachingCredentialsProvider.Cached? + + public init() {} + + public func store(_ credentials: CachingCredentialsProvider.Cached) async throws { + cached = credentials + } + + public func retrieve() async -> CachingCredentialsProvider.Cached? { + cached + } + + public func clear() async { cached = nil } } From d2c10ea9fd63b4368cd626f5bf176932fc1922ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Tue, 16 Sep 2025 13:56:40 +0200 Subject: [PATCH 08/32] Change --- .changes/connection-credentials | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changes/connection-credentials diff --git a/.changes/connection-credentials b/.changes/connection-credentials new file mode 100644 index 000000000..d60db7942 --- /dev/null +++ b/.changes/connection-credentials @@ -0,0 +1 @@ +patch type="added" "Abstract credential providers for easier token fetching" From f20c249c347017a706966d0cec2d34567a281e47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Wed, 17 Sep 2025 09:32:29 +0200 Subject: [PATCH 09/32] Expose cached, naming --- .../LiveKit/Auth/ConnectionCredentials.swift | 46 +++++++++++-------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/Sources/LiveKit/Auth/ConnectionCredentials.swift b/Sources/LiveKit/Auth/ConnectionCredentials.swift index 083373a9f..98f2f240b 100644 --- a/Sources/LiveKit/Auth/ConnectionCredentials.swift +++ b/Sources/LiveKit/Auth/ConnectionCredentials.swift @@ -16,6 +16,8 @@ import Foundation +#warning("Fix camel case after deploying backend") + /// `ConnectionCredentials` represent the credentials needed for connecting to a new Room. /// - SeeAlso: [LiveKit's Authentication Documentation](https://docs.livekit.io/home/get-started/authentication/) for more information. public enum ConnectionCredentials { @@ -132,7 +134,7 @@ public extension TokenServer { public struct SandboxTokenServer: TokenServer { public let url = URL(string: "https://cloud-api.livekit.io/api/sandbox/connection-details")! public var headers: [String: String] { - ["X-Sandbox-ID": id.trimmingCharacters(in: CharacterSet(charactersIn: "\""))] + ["X-Sandbox-ID": id] } /// The sandbox ID provided by LiveKit Cloud. @@ -140,13 +142,13 @@ public struct SandboxTokenServer: TokenServer { /// Initialize with a sandbox ID from LiveKit Cloud. public init(id: String) { - self.id = id + self.id = id.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) } } // MARK: - Cache -/// `CachingCredentialsProvider` handles caching of credentials from any other `CredentialsProvider` using configurable storage. +/// `CachingCredentialsProvider` handles caching of credentials from any other `CredentialsProvider` using configurable store. public actor CachingCredentialsProvider: CredentialsProvider, Loggable { /// A tuple containing the request and response that were cached. public typealias Cached = (ConnectionCredentials.Request, ConnectionCredentials.Response) @@ -155,25 +157,25 @@ public actor CachingCredentialsProvider: CredentialsProvider, Loggable { private let provider: CredentialsProvider private let validator: Validator - private let storage: CredentialsStorage + private let store: CredentialsStore /// Initialize a caching wrapper around any credentials provider. /// - Parameters: /// - provider: The underlying credentials provider to wrap - /// - storage: The storage implementation to use for caching (defaults to in-memory storage) + /// - 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( _ provider: CredentialsProvider, - storage: CredentialsStorage = InMemoryCredentialsStorage(), + store: CredentialsStore = InMemoryCredentialsStore(), validator: @escaping Validator = { _, res in res.hasValidToken() } ) { self.provider = provider - self.storage = storage + self.store = store self.validator = validator } public func fetch(_ request: ConnectionCredentials.Request) async throws -> ConnectionCredentials.Response { - if let (cachedRequest, cachedResponse) = await storage.retrieve(), + if let (cachedRequest, cachedResponse) = await store.retrieve(), cachedRequest == request, validator(cachedRequest, cachedResponse) { @@ -182,23 +184,29 @@ public actor CachingCredentialsProvider: CredentialsProvider, Loggable { } let response = try await provider.fetch(request) - try await storage.store((request, response)) + await store.store((request, response)) return response } /// Invalidate the cached credentials, forcing a fresh fetch on the next request. public func invalidate() async { - await storage.clear() + await store.clear() + } + + /// Get the cached credentials + /// - Returns: The cached credentials if found, nil otherwise + public func getCachedCredentials() async -> CachingCredentialsProvider.Cached? { + await store.retrieve() } } -// MARK: - Storage +// MARK: - Store -/// Protocol for abstract storage that can persist and retrieve a single cached credential pair. -/// Implement this protocol to create custom storage implementations e.g. for Keychain. -public protocol CredentialsStorage: Sendable { - /// Store credentials in the storage (replaces any existing credentials) - func store(_ credentials: CachingCredentialsProvider.Cached) async throws +/// Protocol for abstract store that can persist and retrieve a single cached credential pair. +/// Implement this protocol to create custom store implementations e.g. for Keychain. +public protocol CredentialsStore: Sendable { + /// Store credentials in the store (replaces any existing credentials) + func store(_ credentials: CachingCredentialsProvider.Cached) async /// Retrieve the cached credentials /// - Returns: The cached credentials if found, nil otherwise @@ -208,13 +216,13 @@ public protocol CredentialsStorage: Sendable { func clear() async } -/// Simple in-memory storage implementation -public actor InMemoryCredentialsStorage: CredentialsStorage { +/// Simple in-memory store implementation +public actor InMemoryCredentialsStore: CredentialsStore { private var cached: CachingCredentialsProvider.Cached? public init() {} - public func store(_ credentials: CachingCredentialsProvider.Cached) async throws { + public func store(_ credentials: CachingCredentialsProvider.Cached) async { cached = credentials } From 44a4b8ebee34dcc3d91d65a00ad1c64effe66d20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Wed, 17 Sep 2025 09:38:01 +0200 Subject: [PATCH 10/32] JSON keys --- .../LiveKit/Auth/ConnectionCredentials.swift | 24 +++++++++++++++---- Sources/LiveKit/Core/Room.swift | 2 +- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/Sources/LiveKit/Auth/ConnectionCredentials.swift b/Sources/LiveKit/Auth/ConnectionCredentials.swift index 98f2f240b..5859cdb12 100644 --- a/Sources/LiveKit/Auth/ConnectionCredentials.swift +++ b/Sources/LiveKit/Auth/ConnectionCredentials.swift @@ -37,6 +37,15 @@ public enum ConnectionCredentials { /// - SeeAlso: [Room Configuration Documentation](https://docs.livekit.io/home/get-started/authentication/#room-configuration) for more info. 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_configuration" + // } + public init( roomName: String? = nil, participantName: String? = nil, @@ -57,12 +66,17 @@ public enum ConnectionCredentials { /// Response containing the credentials needed to connect to a room. public struct Response: Decodable, Sendable { /// The WebSocket URL for the LiveKit server. - let serverUrl: URL + let serverURL: URL /// The JWT token containing participant permissions and metadata. let participantToken: String - public init(serverUrl: URL, participantToken: String) { - self.serverUrl = serverUrl + enum CodingKeys: String, CodingKey { + case serverURL = "serverUrl" + case participantToken + } + + public init(serverURL: URL, participantToken: String) { + self.serverURL = serverURL self.participantToken = participantToken } } @@ -117,11 +131,11 @@ public extension TokenServer { 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 sandbox token server, no response") + throw LiveKitError(.network, message: "Error generating token from the token server, no response") } guard (200 ... 299).contains(httpResponse.statusCode) else { - throw LiveKitError(.network, message: "Error generating token from sandbox token server, received \(httpResponse)") + throw LiveKitError(.network, message: "Error generating token from the token server, received \(httpResponse)") } return try JSONDecoder().decode(ConnectionCredentials.Response.self, from: data) diff --git a/Sources/LiveKit/Core/Room.swift b/Sources/LiveKit/Core/Room.swift index 354fcaf08..adf58c2ce 100644 --- a/Sources/LiveKit/Core/Room.swift +++ b/Sources/LiveKit/Core/Room.swift @@ -428,7 +428,7 @@ public class Room: NSObject, @unchecked Sendable, ObservableObject, Loggable { roomOptions: RoomOptions? = nil) async throws { let credentials = try await credentialsProvider.fetch(credentialsOptions) - try await connect(url: credentials.serverUrl.absoluteString, token: credentials.participantToken, connectOptions: connectOptions, roomOptions: roomOptions) + try await connect(url: credentials.serverURL.absoluteString, token: credentials.participantToken, connectOptions: connectOptions, roomOptions: roomOptions) } @objc From 69d2ce0d8d21a5418bd812d6705e7f90314c671c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Wed, 17 Sep 2025 10:32:38 +0200 Subject: [PATCH 11/32] Cache provider --- Sources/LiveKit/Core/Room.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/LiveKit/Core/Room.swift b/Sources/LiveKit/Core/Room.swift index adf58c2ce..992252fc6 100644 --- a/Sources/LiveKit/Core/Room.swift +++ b/Sources/LiveKit/Core/Room.swift @@ -82,6 +82,9 @@ public class Room: NSObject, @unchecked Sendable, ObservableObject, Loggable { @objc public var publishersCount: Int { _state.numPublishers } + // Credentials + public var credentialsProvider: (any CredentialsProvider)? + // expose engine's vars @objc public var url: String? { _state.url?.absoluteString } @@ -429,6 +432,7 @@ public class Room: NSObject, @unchecked Sendable, ObservableObject, Loggable { { let credentials = try await credentialsProvider.fetch(credentialsOptions) try await connect(url: credentials.serverURL.absoluteString, token: credentials.participantToken, connectOptions: connectOptions, roomOptions: roomOptions) + self.credentialsProvider = credentialsProvider } @objc From 1d12469beae8521974c505e0b970c885751ea8fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Wed, 17 Sep 2025 11:29:59 +0200 Subject: [PATCH 12/32] Log --- Sources/LiveKit/Auth/ConnectionCredentials.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/LiveKit/Auth/ConnectionCredentials.swift b/Sources/LiveKit/Auth/ConnectionCredentials.swift index 5859cdb12..97149ba69 100644 --- a/Sources/LiveKit/Auth/ConnectionCredentials.swift +++ b/Sources/LiveKit/Auth/ConnectionCredentials.swift @@ -134,7 +134,7 @@ public extension TokenServer { throw LiveKitError(.network, message: "Error generating token from the token server, no response") } - guard (200 ... 299).contains(httpResponse.statusCode) else { + guard (200 ..< 300).contains(httpResponse.statusCode) else { throw LiveKitError(.network, message: "Error generating token from the token server, received \(httpResponse)") } @@ -170,8 +170,8 @@ public actor CachingCredentialsProvider: CredentialsProvider, Loggable { public typealias Validator = (ConnectionCredentials.Request, ConnectionCredentials.Response) -> Bool private let provider: CredentialsProvider - private let validator: Validator private let store: CredentialsStore + private let validator: Validator /// Initialize a caching wrapper around any credentials provider. /// - Parameters: @@ -197,6 +197,7 @@ public actor CachingCredentialsProvider: CredentialsProvider, Loggable { return cachedResponse } + log("Requesting new credentials", .debug) let response = try await provider.fetch(request) await store.store((request, response)) return response From 81096848a86e54a3664c3668bac500e848db946c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Fri, 19 Sep 2025 08:59:50 +0200 Subject: [PATCH 13/32] Renaming --- ...ionCredentials.swift => TokenSource.swift} | 78 +++++++++---------- Sources/LiveKit/Core/Room.swift | 13 ++-- 2 files changed, 46 insertions(+), 45 deletions(-) rename Sources/LiveKit/Auth/{ConnectionCredentials.swift => TokenSource.swift} (76%) diff --git a/Sources/LiveKit/Auth/ConnectionCredentials.swift b/Sources/LiveKit/Auth/TokenSource.swift similarity index 76% rename from Sources/LiveKit/Auth/ConnectionCredentials.swift rename to Sources/LiveKit/Auth/TokenSource.swift index 97149ba69..39fa0429a 100644 --- a/Sources/LiveKit/Auth/ConnectionCredentials.swift +++ b/Sources/LiveKit/Auth/TokenSource.swift @@ -18,9 +18,9 @@ import Foundation #warning("Fix camel case after deploying backend") -/// `ConnectionCredentials` represent the credentials needed for connecting to a new Room. +/// `Token` represent the credentials needed for connecting to a new Room. /// - SeeAlso: [LiveKit's Authentication Documentation](https://docs.livekit.io/home/get-started/authentication/) for more information. -public enum ConnectionCredentials { +public enum Token { /// Request parameters for generating connection credentials. public struct Request: Encodable, Sendable, Equatable { /// The name of the room being requested when generating credentials. @@ -89,14 +89,14 @@ public enum ConnectionCredentials { /// Protocol for types that can provide connection credentials. /// Implement this protocol to create custom credential providers (e.g., fetching from your backend API). -public protocol CredentialsProvider: Sendable { - func fetch(_ request: ConnectionCredentials.Request) async throws -> ConnectionCredentials.Response +public protocol TokenSource: Sendable { + func fetch(_ request: Token.Request) async throws -> Token.Response } -/// `ConnectionCredentials.Literal` contains a single set of credentials, hard-coded or acquired from a static source. -/// - Note: It does not support refresing credentials. -extension ConnectionCredentials.Literal: CredentialsProvider { - public func fetch(_: ConnectionCredentials.Request) async throws -> ConnectionCredentials.Response { +/// `Token.Literal` contains a single set of credentials, hard-coded or acquired from a static source. +/// - Note: It does not support refreshing credentials. +extension Token.Literal: TokenSource { + public func fetch(_: Token.Request) async throws -> Token.Response { self } } @@ -105,8 +105,8 @@ extension ConnectionCredentials.Literal: CredentialsProvider { /// 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. -/// - Note: The response is expected to be a `ConnectionCredentials.Response` object. -public protocol TokenServer: CredentialsProvider { +/// - Note: The response is expected to be a `Token.Response` object. +public protocol TokenEndpoint: TokenSource { /// The URL endpoint for token generation. var url: URL { get } /// The HTTP method to use (defaults to "POST"). @@ -115,11 +115,11 @@ public protocol TokenServer: CredentialsProvider { var headers: [String: String] { get } } -public extension TokenServer { +public extension TokenEndpoint { var method: String { "POST" } var headers: [String: String] { [:] } - func fetch(_ request: ConnectionCredentials.Request) async throws -> ConnectionCredentials.Response { + func fetch(_ request: Token.Request) async throws -> Token.Response { var urlRequest = URLRequest(url: url) urlRequest.httpMethod = method @@ -138,14 +138,14 @@ public extension TokenServer { throw LiveKitError(.network, message: "Error generating token from the token server, received \(httpResponse)") } - return try JSONDecoder().decode(ConnectionCredentials.Response.self, from: data) + return try JSONDecoder().decode(Token.Response.self, from: data) } } -/// `SandboxTokenServer` queries LiveKit Sandbox token server for credentials, +/// `Sandbox` queries LiveKit Sandbox token server for credentials, /// which supports quick prototyping/getting started types of use cases. -/// - Warning: This token provider is **INSECURE** and should **NOT** be used in production. -public struct SandboxTokenServer: TokenServer { +/// - Warning: This token endpoint is **INSECURE** and should **NOT** be used in production. +public struct Sandbox: TokenEndpoint { public let url = URL(string: "https://cloud-api.livekit.io/api/sandbox/connection-details")! public var headers: [String: String] { ["X-Sandbox-ID": id] @@ -162,33 +162,33 @@ public struct SandboxTokenServer: TokenServer { // MARK: - Cache -/// `CachingCredentialsProvider` handles caching of credentials from any other `CredentialsProvider` using configurable store. -public actor CachingCredentialsProvider: CredentialsProvider, Loggable { +/// `CachingTokenSource` handles caching of credentials from any other `TokenSource` using configurable store. +public actor CachingTokenSource: TokenSource, Loggable { /// A tuple containing the request and response that were cached. - public typealias Cached = (ConnectionCredentials.Request, ConnectionCredentials.Response) + public typealias Cached = (Token.Request, Token.Response) /// A closure that validates whether cached credentials are still valid. - public typealias Validator = (ConnectionCredentials.Request, ConnectionCredentials.Response) -> Bool + public typealias TokenValidator = (Token.Request, Token.Response) -> Bool - private let provider: CredentialsProvider - private let store: CredentialsStore - private let validator: Validator + private let source: TokenSource + private let store: TokenStore + private let validator: TokenValidator /// Initialize a caching wrapper around any credentials provider. /// - Parameters: - /// - provider: The underlying credentials provider to wrap + /// - source: The underlying token source to wrap /// - 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( - _ provider: CredentialsProvider, - store: CredentialsStore = InMemoryCredentialsStore(), - validator: @escaping Validator = { _, res in res.hasValidToken() } + _ source: TokenSource, + store: TokenStore = InMemoryTokenStore(), + validator: @escaping TokenValidator = { _, res in res.hasValidToken() } ) { - self.provider = provider + self.source = source self.store = store self.validator = validator } - public func fetch(_ request: ConnectionCredentials.Request) async throws -> ConnectionCredentials.Response { + public func fetch(_ request: Token.Request) async throws -> Token.Response { if let (cachedRequest, cachedResponse) = await store.retrieve(), cachedRequest == request, validator(cachedRequest, cachedResponse) @@ -198,7 +198,7 @@ public actor CachingCredentialsProvider: CredentialsProvider, Loggable { } log("Requesting new credentials", .debug) - let response = try await provider.fetch(request) + let response = try await source.fetch(request) await store.store((request, response)) return response } @@ -210,7 +210,7 @@ public actor CachingCredentialsProvider: CredentialsProvider, Loggable { /// Get the cached credentials /// - Returns: The cached credentials if found, nil otherwise - public func getCachedCredentials() async -> CachingCredentialsProvider.Cached? { + public func getCachedCredentials() async -> CachingTokenSource.Cached? { await store.retrieve() } } @@ -219,29 +219,29 @@ public actor CachingCredentialsProvider: CredentialsProvider, Loggable { /// Protocol for abstract store that can persist and retrieve a single cached credential pair. /// Implement this protocol to create custom store implementations e.g. for Keychain. -public protocol CredentialsStore: Sendable { +public protocol TokenStore: Sendable { /// Store credentials in the store (replaces any existing credentials) - func store(_ credentials: CachingCredentialsProvider.Cached) async + func store(_ credentials: CachingTokenSource.Cached) async /// Retrieve the cached credentials /// - Returns: The cached credentials if found, nil otherwise - func retrieve() async -> CachingCredentialsProvider.Cached? + func retrieve() async -> CachingTokenSource.Cached? /// Clear the stored credentials func clear() async } /// Simple in-memory store implementation -public actor InMemoryCredentialsStore: CredentialsStore { - private var cached: CachingCredentialsProvider.Cached? +public actor InMemoryTokenStore: TokenStore { + private var cached: CachingTokenSource.Cached? public init() {} - public func store(_ credentials: CachingCredentialsProvider.Cached) async { + public func store(_ credentials: CachingTokenSource.Cached) async { cached = credentials } - public func retrieve() async -> CachingCredentialsProvider.Cached? { + public func retrieve() async -> CachingTokenSource.Cached? { cached } @@ -252,7 +252,7 @@ public actor InMemoryCredentialsStore: CredentialsStore { // MARK: - Validation -public extension ConnectionCredentials.Response { +public extension Token.Response { func hasValidToken(withTolerance tolerance: TimeInterval = 60) -> Bool { let parts = participantToken.components(separatedBy: ".") guard parts.count == 3 else { diff --git a/Sources/LiveKit/Core/Room.swift b/Sources/LiveKit/Core/Room.swift index 992252fc6..56598b864 100644 --- a/Sources/LiveKit/Core/Room.swift +++ b/Sources/LiveKit/Core/Room.swift @@ -83,7 +83,7 @@ public class Room: NSObject, @unchecked Sendable, ObservableObject, Loggable { public var publishersCount: Int { _state.numPublishers } // Credentials - public var credentialsProvider: (any CredentialsProvider)? + public var tokenSource: (any TokenSource)? // expose engine's vars @objc @@ -425,14 +425,15 @@ public class Room: NSObject, @unchecked Sendable, ObservableObject, Loggable { log("Connected to \(String(describing: self))", .info) } - public func connect(credentialsProvider: CredentialsProvider, - credentialsOptions: ConnectionCredentials.Options = .init(), + public func connect(tokenSource: TokenSource, + tokenOptions: Token.Options = .init(), connectOptions: ConnectOptions? = nil, roomOptions: RoomOptions? = nil) async throws { - let credentials = try await credentialsProvider.fetch(credentialsOptions) - try await connect(url: credentials.serverURL.absoluteString, token: credentials.participantToken, connectOptions: connectOptions, roomOptions: roomOptions) - self.credentialsProvider = credentialsProvider + self.tokenSource = tokenSource + + let token = try await tokenSource.fetch(tokenOptions) + try await connect(url: token.serverURL.absoluteString, token: token.participantToken, connectOptions: connectOptions, roomOptions: roomOptions) } @objc From 6b2eb9cd41f0e4df6cfd753be5ff936fea7723a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Fri, 19 Sep 2025 09:08:36 +0200 Subject: [PATCH 14/32] Fix tests --- .../TokenSourceTests.swift} | 164 +++++++++--------- 1 file changed, 82 insertions(+), 82 deletions(-) rename Tests/LiveKitTests/{ConnectionCredentialsTests.swift => Auth/TokenSourceTests.swift} (50%) diff --git a/Tests/LiveKitTests/ConnectionCredentialsTests.swift b/Tests/LiveKitTests/Auth/TokenSourceTests.swift similarity index 50% rename from Tests/LiveKitTests/ConnectionCredentialsTests.swift rename to Tests/LiveKitTests/Auth/TokenSourceTests.swift index cadf39ee6..db51079ec 100644 --- a/Tests/LiveKitTests/ConnectionCredentialsTests.swift +++ b/Tests/LiveKitTests/Auth/TokenSourceTests.swift @@ -18,9 +18,9 @@ import Foundation @testable import LiveKit import XCTest -class ConnectionCredentialsTests: LKTestCase { - actor MockValidJWTProvider: CredentialsProvider { - let serverUrl = URL(string: "wss://test.livekit.io")! +class TokenSourceTests: LKTestCase { + actor MockValidJWTSource: TokenSource { + let serverURL = URL(string: "wss://test.livekit.io")! let participantName: String var callCount = 0 @@ -28,7 +28,7 @@ class ConnectionCredentialsTests: LKTestCase { self.participantName = participantName } - func fetch(_ request: ConnectionCredentials.Request) async throws -> ConnectionCredentials.Response { + func fetch(_ request: Token.Request) async throws -> Token.Response { callCount += 1 let tokenGenerator = TokenGenerator( @@ -41,32 +41,32 @@ class ConnectionCredentialsTests: LKTestCase { let token = try tokenGenerator.sign() - return ConnectionCredentials.Response( - serverUrl: serverUrl, + return Token.Response( + serverURL: serverURL, participantToken: token ) } } - actor MockInvalidJWTProvider: CredentialsProvider { - let serverUrl = URL(string: "wss://test.livekit.io")! + actor MockInvalidJWTSource: TokenSource { + let serverURL = URL(string: "wss://test.livekit.io")! var callCount = 0 - func fetch(_: ConnectionCredentials.Request) async throws -> ConnectionCredentials.Response { + func fetch(_: Token.Request) async throws -> Token.Response { callCount += 1 - return ConnectionCredentials.Response( - serverUrl: serverUrl, + return Token.Response( + serverURL: serverURL, participantToken: "invalid.jwt.token" ) } } - actor MockExpiredJWTProvider: CredentialsProvider { - let serverUrl = URL(string: "wss://test.livekit.io")! + actor MockExpiredJWTSource: TokenSource { + let serverURL = URL(string: "wss://test.livekit.io")! var callCount = 0 - func fetch(_ request: ConnectionCredentials.Request) async throws -> ConnectionCredentials.Response { + func fetch(_ request: Token.Request) async throws -> Token.Response { callCount += 1 let tokenGenerator = TokenGenerator( @@ -80,176 +80,176 @@ class ConnectionCredentialsTests: LKTestCase { let token = try tokenGenerator.sign() - return ConnectionCredentials.Response( - serverUrl: serverUrl, + return Token.Response( + serverURL: serverURL, participantToken: token ) } } func testValidJWTCaching() async throws { - let mockProvider = MockValidJWTProvider(participantName: "alice") - let cachingProvider = CachingCredentialsProvider(mockProvider) + let mockSource = MockValidJWTSource(participantName: "alice") + let cachingSource = CachingTokenSource(mockSource) - let request = ConnectionCredentials.Request( + let request = Token.Request( roomName: "test-room", participantName: "alice", participantIdentity: "alice-id" ) - let response1 = try await cachingProvider.fetch(request) - let callCount1 = await mockProvider.callCount + let response1 = try await cachingSource.fetch(request) + let callCount1 = await mockSource.callCount XCTAssertEqual(callCount1, 1) - XCTAssertEqual(response1.serverUrl.absoluteString, "wss://test.livekit.io") + XCTAssertEqual(response1.serverURL.absoluteString, "wss://test.livekit.io") XCTAssertTrue(response1.hasValidToken(), "Generated token should be valid") - let response2 = try await cachingProvider.fetch(request) - let callCount2 = await mockProvider.callCount + 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) + XCTAssertEqual(response2.serverURL, response1.serverURL) - let differentRequest = ConnectionCredentials.Request( + let differentRequest = Token.Request( roomName: "different-room", participantName: "alice", participantIdentity: "alice-id" ) - let response3 = try await cachingProvider.fetch(differentRequest) - let callCount3 = await mockProvider.callCount + let response3 = try await cachingSource.fetch(differentRequest) + let callCount3 = await mockSource.callCount XCTAssertEqual(callCount3, 2) XCTAssertNotEqual(response3.participantToken, response1.participantToken) - await cachingProvider.invalidate() - _ = try await cachingProvider.fetch(request) - let callCount4 = await mockProvider.callCount + await cachingSource.invalidate() + _ = try await cachingSource.fetch(request) + let callCount4 = await mockSource.callCount XCTAssertEqual(callCount4, 3) } func testInvalidJWTHandling() async throws { - let mockInvalidProvider = MockInvalidJWTProvider() - let cachingProvider = CachingCredentialsProvider(mockInvalidProvider) + let mockInvalidSource = MockInvalidJWTSource() + let cachingSource = CachingTokenSource(mockInvalidSource) - let request = ConnectionCredentials.Request( + let request = Token.Request( roomName: "test-room", participantName: "bob", participantIdentity: "bob-id" ) - let response1 = try await cachingProvider.fetch(request) - let callCount1 = await mockInvalidProvider.callCount + 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 cachingProvider.fetch(request) - let callCount2 = await mockInvalidProvider.callCount + let response2 = try await cachingSource.fetch(request) + let callCount2 = await mockInvalidSource.callCount XCTAssertEqual(callCount2, 2) XCTAssertEqual(response2.participantToken, response1.participantToken) - let mockExpiredProvider = MockExpiredJWTProvider() - let cachingProviderExpired = CachingCredentialsProvider(mockExpiredProvider) + let mockExpiredSource = MockExpiredJWTSource() + let cachingSourceExpired = CachingTokenSource(mockExpiredSource) - let response3 = try await cachingProviderExpired.fetch(request) - let expiredCallCount1 = await mockExpiredProvider.callCount + 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 cachingProviderExpired.fetch(request) - let expiredCallCount2 = await mockExpiredProvider.callCount + _ = try await cachingSourceExpired.fetch(request) + let expiredCallCount2 = await mockExpiredSource.callCount XCTAssertEqual(expiredCallCount2, 2) } func testCustomValidator() async throws { - let mockProvider = MockValidJWTProvider(participantName: "charlie") + let mockSource = MockValidJWTSource(participantName: "charlie") - let customValidator: CachingCredentialsProvider.Validator = { request, response in + let customValidator: CachingTokenSource.TokenValidator = { request, response in request.participantName == "charlie" && response.hasValidToken() } - let cachingProvider = CachingCredentialsProvider(mockProvider, validator: customValidator) + let cachingSource = CachingTokenSource(mockSource, validator: customValidator) - let charlieRequest = ConnectionCredentials.Request( + let charlieRequest = Token.Request( roomName: "test-room", participantName: "charlie", participantIdentity: "charlie-id" ) - let response1 = try await cachingProvider.fetch(charlieRequest) - let callCount1 = await mockProvider.callCount + let response1 = try await cachingSource.fetch(charlieRequest) + let callCount1 = await mockSource.callCount XCTAssertEqual(callCount1, 1) XCTAssertTrue(response1.hasValidToken()) - let response2 = try await cachingProvider.fetch(charlieRequest) - let callCount2 = await mockProvider.callCount + let response2 = try await cachingSource.fetch(charlieRequest) + let callCount2 = await mockSource.callCount XCTAssertEqual(callCount2, 1) XCTAssertEqual(response2.participantToken, response1.participantToken) - let aliceRequest = ConnectionCredentials.Request( + let aliceRequest = Token.Request( roomName: "test-room", participantName: "alice", participantIdentity: "alice-id" ) - _ = try await cachingProvider.fetch(aliceRequest) - let callCount3 = await mockProvider.callCount + _ = try await cachingSource.fetch(aliceRequest) + let callCount3 = await mockSource.callCount XCTAssertEqual(callCount3, 2) - _ = try await cachingProvider.fetch(aliceRequest) - let callCount4 = await mockProvider.callCount + _ = try await cachingSource.fetch(aliceRequest) + let callCount4 = await mockSource.callCount XCTAssertEqual(callCount4, 3) - let tokenMockProvider = MockValidJWTProvider(participantName: "dave") - let tokenContentValidator: CachingCredentialsProvider.Validator = { request, response in + let tokenMockSource = MockValidJWTSource(participantName: "dave") + let tokenContentValidator: CachingTokenSource.TokenValidator = { request, response in request.roomName == "test-room" && response.hasValidToken() } - let tokenCachingProvider = CachingCredentialsProvider(tokenMockProvider, validator: tokenContentValidator) + let tokenCachingSource = CachingTokenSource(tokenMockSource, validator: tokenContentValidator) - let roomRequest = ConnectionCredentials.Request( + let roomRequest = Token.Request( roomName: "test-room", participantName: "dave", participantIdentity: "dave-id" ) - _ = try await tokenCachingProvider.fetch(roomRequest) - let tokenCallCount1 = await tokenMockProvider.callCount + _ = try await tokenCachingSource.fetch(roomRequest) + let tokenCallCount1 = await tokenMockSource.callCount XCTAssertEqual(tokenCallCount1, 1) - _ = try await tokenCachingProvider.fetch(roomRequest) - let tokenCallCount2 = await tokenMockProvider.callCount + _ = try await tokenCachingSource.fetch(roomRequest) + let tokenCallCount2 = await tokenMockSource.callCount XCTAssertEqual(tokenCallCount2, 1) - let differentRoomRequest = ConnectionCredentials.Request( + let differentRoomRequest = Token.Request( roomName: "different-room", participantName: "dave", participantIdentity: "dave-id" ) - _ = try await tokenCachingProvider.fetch(differentRoomRequest) - let tokenCallCount3 = await tokenMockProvider.callCount + _ = try await tokenCachingSource.fetch(differentRoomRequest) + let tokenCallCount3 = await tokenMockSource.callCount XCTAssertEqual(tokenCallCount3, 2) - _ = try await tokenCachingProvider.fetch(differentRoomRequest) - let tokenCallCount4 = await tokenMockProvider.callCount + _ = try await tokenCachingSource.fetch(differentRoomRequest) + let tokenCallCount4 = await tokenMockSource.callCount XCTAssertEqual(tokenCallCount4, 3) } func testConcurrentAccess() async throws { - let mockProvider = MockValidJWTProvider(participantName: "concurrent-test") - let cachingProvider = CachingCredentialsProvider(mockProvider) + let mockSource = MockValidJWTSource(participantName: "concurrent-test") + let cachingSource = CachingTokenSource(mockSource) - let request = ConnectionCredentials.Request( + let request = Token.Request( roomName: "concurrent-room", participantName: "concurrent-user", participantIdentity: "concurrent-id" ) - let initialResponse = try await cachingProvider.fetch(request) - let initialCallCount = await mockProvider.callCount + let initialResponse = try await cachingSource.fetch(request) + let initialCallCount = await mockSource.callCount XCTAssertEqual(initialCallCount, 1) - async let fetch1 = cachingProvider.fetch(request) - async let fetch2 = cachingProvider.fetch(request) - async let fetch3 = cachingProvider.fetch(request) + 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] @@ -257,11 +257,11 @@ class ConnectionCredentialsTests: LKTestCase { 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) + XCTAssertEqual(responses[0].serverURL, initialResponse.serverURL) + XCTAssertEqual(responses[1].serverURL, initialResponse.serverURL) + XCTAssertEqual(responses[2].serverURL, initialResponse.serverURL) - let finalCallCount = await mockProvider.callCount + let finalCallCount = await mockSource.callCount XCTAssertEqual(finalCallCount, 1) } } From 96c4ba5fdbd8a4971fbf67f396587d7b9bced9b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Fri, 19 Sep 2025 09:11:02 +0200 Subject: [PATCH 15/32] Move Sandbox --- Sources/LiveKit/Auth/Sandbox.swift | 35 ++++++++++++++++++++++++++ Sources/LiveKit/Auth/TokenSource.swift | 25 +++--------------- 2 files changed, 39 insertions(+), 21 deletions(-) create mode 100644 Sources/LiveKit/Auth/Sandbox.swift diff --git a/Sources/LiveKit/Auth/Sandbox.swift b/Sources/LiveKit/Auth/Sandbox.swift new file mode 100644 index 000000000..2e7b5a6b5 --- /dev/null +++ b/Sources/LiveKit/Auth/Sandbox.swift @@ -0,0 +1,35 @@ +/* + * 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 + +/// `Sandbox` queries LiveKit Sandbox token server for credentials, +/// which supports quick prototyping/getting started types of use cases. +/// - Warning: This token endpoint is **INSECURE** and should **NOT** be used in production. +public struct Sandbox: TokenEndpoint { + public let url = URL(string: "https://cloud-api.livekit.io/api/sandbox/connection-details")! + public var headers: [String: String] { + ["X-Sandbox-ID": id] + } + + /// The sandbox ID provided by LiveKit Cloud. + public let id: String + + /// Initialize with a sandbox ID from LiveKit Cloud. + public init(id: String) { + self.id = id.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } +} diff --git a/Sources/LiveKit/Auth/TokenSource.swift b/Sources/LiveKit/Auth/TokenSource.swift index 39fa0429a..1a82d7816 100644 --- a/Sources/LiveKit/Auth/TokenSource.swift +++ b/Sources/LiveKit/Auth/TokenSource.swift @@ -18,6 +18,8 @@ import Foundation #warning("Fix camel case after deploying backend") +// MARK: - Token + /// `Token` represent the credentials needed for connecting to a new Room. /// - SeeAlso: [LiveKit's Authentication Documentation](https://docs.livekit.io/home/get-started/authentication/) for more information. public enum Token { @@ -85,7 +87,7 @@ public enum Token { public typealias Literal = Response } -// MARK: - Provider +// MARK: - Source /// Protocol for types that can provide connection credentials. /// Implement this protocol to create custom credential providers (e.g., fetching from your backend API). @@ -94,14 +96,13 @@ public protocol TokenSource: Sendable { } /// `Token.Literal` contains a single set of credentials, hard-coded or acquired from a static source. -/// - Note: It does not support refreshing credentials. extension Token.Literal: TokenSource { public func fetch(_: Token.Request) async throws -> Token.Response { self } } -// MARK: - Token Server +// MARK: - Endpoint /// 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. @@ -142,24 +143,6 @@ public extension TokenEndpoint { } } -/// `Sandbox` queries LiveKit Sandbox token server for credentials, -/// which supports quick prototyping/getting started types of use cases. -/// - Warning: This token endpoint is **INSECURE** and should **NOT** be used in production. -public struct Sandbox: TokenEndpoint { - public let url = URL(string: "https://cloud-api.livekit.io/api/sandbox/connection-details")! - public var headers: [String: String] { - ["X-Sandbox-ID": id] - } - - /// The sandbox ID provided by LiveKit Cloud. - public let id: String - - /// Initialize with a sandbox ID from LiveKit Cloud. - public init(id: String) { - self.id = id.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } -} - // MARK: - Cache /// `CachingTokenSource` handles caching of credentials from any other `TokenSource` using configurable store. From 43df84e5936509dbc4ddf120d2461924cefb8899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Fri, 19 Sep 2025 09:16:59 +0200 Subject: [PATCH 16/32] Public, comments --- Sources/LiveKit/Auth/TokenSource.swift | 30 ++++++++++++++++++-------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/Sources/LiveKit/Auth/TokenSource.swift b/Sources/LiveKit/Auth/TokenSource.swift index 1a82d7816..982dca2d0 100644 --- a/Sources/LiveKit/Auth/TokenSource.swift +++ b/Sources/LiveKit/Auth/TokenSource.swift @@ -26,18 +26,19 @@ public enum Token { /// Request parameters for generating connection credentials. public struct Request: Encodable, Sendable, Equatable { /// The name of the room being requested when generating credentials. - let roomName: String? + public let roomName: String? /// The name of the participant being requested for this client when generating credentials. - let participantName: String? + public let participantName: String? /// The identity of the participant being requested for this client when generating credentials. - let participantIdentity: String? + public let participantIdentity: String? /// Any participant metadata being included along with the credentials generation operation. - let participantMetadata: String? + public let participantMetadata: String? /// Any participant attributes being included along with the credentials generation operation. - let participantAttributes: [String: String]? - /// A `RoomConfiguration` object can be passed to request extra parameters should be included when generating connection credentials - dispatching agents, etc. + public let participantAttributes: [String: String]? + /// A `RoomConfiguration` object can be passed to request extra parameters when generating connection credentials. + /// Used for advanced room configuration like dispatching agents, setting room limits, etc. /// - SeeAlso: [Room Configuration Documentation](https://docs.livekit.io/home/get-started/authentication/#room-configuration) for more info. - let roomConfiguration: RoomConfiguration? + public let roomConfiguration: RoomConfiguration? // enum CodingKeys: String, CodingKey { // case roomName = "room_name" @@ -68,9 +69,9 @@ public enum Token { /// Response containing the credentials needed to connect to a room. public struct Response: Decodable, Sendable { /// The WebSocket URL for the LiveKit server. - let serverURL: URL + public let serverURL: URL /// The JWT token containing participant permissions and metadata. - let participantToken: String + public let participantToken: String enum CodingKeys: String, CodingKey { case serverURL = "serverUrl" @@ -92,6 +93,10 @@ public enum Token { /// Protocol for types that can provide connection credentials. /// Implement this protocol to create custom credential providers (e.g., fetching from your backend API). public protocol TokenSource: Sendable { + /// Fetch connection credentials for the given request. + /// - Parameter request: The token request containing room and participant information + /// - Returns: A token response containing the server URL and participant token + /// - Throws: An error if the token generation fails func fetch(_ request: Token.Request) async throws -> Token.Response } @@ -150,6 +155,10 @@ public actor CachingTokenSource: TokenSource, Loggable { /// A tuple containing the request and response that were cached. public typealias Cached = (Token.Request, Token.Response) /// A closure that validates whether cached credentials are still valid. + /// - Parameters: + /// - request: The original token request + /// - response: The cached token response + /// - Returns: `true` if the cached credentials are still valid, `false` otherwise public typealias TokenValidator = (Token.Request, Token.Response) -> Bool private let source: TokenSource @@ -236,6 +245,9 @@ public actor InMemoryTokenStore: TokenStore { // MARK: - Validation public extension Token.Response { + /// Validates whether the JWT token is still valid. + /// - 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 { let parts = participantToken.components(separatedBy: ".") guard parts.count == 3 else { From 18ea71bd4f178a2c6cc338324fe595e197999792 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Fri, 19 Sep 2025 09:25:32 +0200 Subject: [PATCH 17/32] Nitpicks --- Sources/LiveKit/Auth/TokenSource.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/LiveKit/Auth/TokenSource.swift b/Sources/LiveKit/Auth/TokenSource.swift index 982dca2d0..4375ba08c 100644 --- a/Sources/LiveKit/Auth/TokenSource.swift +++ b/Sources/LiveKit/Auth/TokenSource.swift @@ -173,7 +173,7 @@ public actor CachingTokenSource: TokenSource, Loggable { public init( _ source: TokenSource, store: TokenStore = InMemoryTokenStore(), - validator: @escaping TokenValidator = { _, res in res.hasValidToken() } + validator: @escaping TokenValidator = { _, response in response.hasValidToken() } ) { self.source = source self.store = store @@ -201,9 +201,9 @@ public actor CachingTokenSource: TokenSource, Loggable { } /// Get the cached credentials - /// - Returns: The cached credentials if found, nil otherwise - public func getCachedCredentials() async -> CachingTokenSource.Cached? { - await store.retrieve() + /// - Returns: The cached token if found, nil otherwise + public func cachedToken() async -> Token.Response? { + await store.retrieve()?.1 } } From cdd5f1c261391f0b43c06e1f3cc546b34297e4c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Fri, 19 Sep 2025 09:27:31 +0200 Subject: [PATCH 18/32] Change --- .changes/connection-credentials | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changes/connection-credentials b/.changes/connection-credentials index d60db7942..37cbf36c7 100644 --- a/.changes/connection-credentials +++ b/.changes/connection-credentials @@ -1 +1 @@ -patch type="added" "Abstract credential providers for easier token fetching" +patch type="added" "Abstract token source for easier token fetching in production and faster integration with sandbox environment" From 3d4579585e42dd0f1fef67dd09566f238a0928cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Fri, 19 Sep 2025 10:35:38 +0200 Subject: [PATCH 19/32] JWT # Conflicts: # Package.swift # Package@swift-6.0.swift --- Package.swift | 6 +- Package@swift-6.0.swift | 6 +- Sources/LiveKit/Auth/JWT.swift | 99 +++++++++++++++++++ Sources/LiveKit/Auth/TokenSource.swift | 36 ++----- .../LiveKitTests/Auth/TokenSourceTests.swift | 4 +- Tests/LiveKitTests/Support/Room.swift | 12 +-- .../LiveKitTests/Support/TokenGenerator.swift | 92 ++--------------- 7 files changed, 130 insertions(+), 125 deletions(-) create mode 100644 Sources/LiveKit/Auth/JWT.swift diff --git a/Package.swift b/Package.swift index 68d9bbd82..40d4c77cb 100644 --- a/Package.swift +++ b/Package.swift @@ -23,10 +23,9 @@ let package = Package( .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.31.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.6.2"), .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( @@ -41,6 +40,7 @@ let package = Package( .product(name: "DequeModule", package: "swift-collections"), .product(name: "OrderedCollections", package: "swift-collections"), .product(name: "Logging", package: "swift-log"), + .product(name: "JWTKit", package: "jwt-kit"), "LKObjCHelpers", ], exclude: [ @@ -57,14 +57,12 @@ let package = Package( name: "LiveKitTests", dependencies: [ "LiveKit", - .product(name: "JWTKit", package: "jwt-kit"), ] ), .testTarget( name: "LiveKitTestsObjC", dependencies: [ "LiveKit", - .product(name: "JWTKit", package: "jwt-kit"), ] ), ], diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index 7fde3d4df..c7d96f282 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -24,10 +24,9 @@ let package = Package( .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.31.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.6.2"), .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( @@ -42,6 +41,7 @@ let package = Package( .product(name: "DequeModule", package: "swift-collections"), .product(name: "OrderedCollections", package: "swift-collections"), .product(name: "Logging", package: "swift-log"), + .product(name: "JWTKit", package: "jwt-kit"), "LKObjCHelpers", ], exclude: [ @@ -58,14 +58,12 @@ let package = Package( name: "LiveKitTests", dependencies: [ "LiveKit", - .product(name: "JWTKit", package: "jwt-kit"), ] ), .testTarget( name: "LiveKitTestsObjC", dependencies: [ "LiveKit", - .product(name: "JWTKit", package: "jwt-kit"), ] ), ], diff --git a/Sources/LiveKit/Auth/JWT.swift b/Sources/LiveKit/Auth/JWT.swift new file mode 100644 index 000000000..b0531594f --- /dev/null +++ b/Sources/LiveKit/Auth/JWT.swift @@ -0,0 +1,99 @@ +/* + * 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 + +public struct LiveKitJWTPayload: JWTPayload, Codable, Equatable { + 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]? + /// 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 + } + } + + /// Expiration time claim + public let exp: ExpirationClaim + /// Issuer claim + public let iss: IssuerClaim + /// Not before claim + public let nbf: NotBeforeClaim + /// Subject claim + public let sub: SubjectClaim + + /// Participant name + public let name: String? + /// Participant metadata + public let metadata: String? + /// Video grants for the participant + public let video: VideoGrant? + + public func verify(using _: JWTSigner) throws { + try nbf.verifyNotBefore() + try exp.verifyNotExpired() + } + + static func fromUnverified(token: String) -> Self? { + try? JWTSigners().unverified(token, as: Self.self) + } +} diff --git a/Sources/LiveKit/Auth/TokenSource.swift b/Sources/LiveKit/Auth/TokenSource.swift index 4375ba08c..866f2e176 100644 --- a/Sources/LiveKit/Auth/TokenSource.swift +++ b/Sources/LiveKit/Auth/TokenSource.swift @@ -249,39 +249,23 @@ public extension Token.Response { /// - 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 { - let parts = participantToken.components(separatedBy: ".") - guard parts.count == 3 else { + guard let jwt = jwt() else { return false } - let payloadData = parts[1] - - struct JWTPayload: Decodable { - let nbf: Double - let exp: Double - } - - guard let payloadJSON = payloadData.base64Decode(), - let payload = try? JSONDecoder().decode(JWTPayload.self, from: payloadJSON) - else { + do { + try jwt.nbf.verifyNotBefore() + try jwt.exp.verifyNotExpired(currentDate: Date().addingTimeInterval(tolerance)) + } catch { return false } - let now = Date().timeIntervalSince1970 - return payload.nbf <= now && payload.exp > now - tolerance + return true } -} - -private extension String { - func base64Decode() -> Data? { - var base64 = self - base64 = base64.replacingOccurrences(of: "-", with: "+") - base64 = base64.replacingOccurrences(of: "_", with: "/") - - while base64.count % 4 != 0 { - base64.append("=") - } - return Data(base64Encoded: base64) + /// Extracts the JWT payload from the participant token. + /// - Returns: The JWT payload if found, nil otherwise + func jwt() -> LiveKitJWTPayload? { + LiveKitJWTPayload.fromUnverified(token: participantToken) } } diff --git a/Tests/LiveKitTests/Auth/TokenSourceTests.swift b/Tests/LiveKitTests/Auth/TokenSourceTests.swift index db51079ec..37841af17 100644 --- a/Tests/LiveKitTests/Auth/TokenSourceTests.swift +++ b/Tests/LiveKitTests/Auth/TokenSourceTests.swift @@ -37,7 +37,7 @@ class TokenSourceTests: LKTestCase { identity: request.participantIdentity ?? "test-identity" ) tokenGenerator.name = request.participantName ?? participantName - tokenGenerator.videoGrant = VideoGrant(room: request.roomName ?? "test-room", roomJoin: true) + tokenGenerator.videoGrant = LiveKitJWTPayload.VideoGrant(room: request.roomName ?? "test-room", roomJoin: true) let token = try tokenGenerator.sign() @@ -76,7 +76,7 @@ class TokenSourceTests: LKTestCase { ttl: -60 ) tokenGenerator.name = request.participantName ?? "test-participant" - tokenGenerator.videoGrant = VideoGrant(room: request.roomName ?? "test-room", roomJoin: true) + tokenGenerator.videoGrant = LiveKitJWTPayload.VideoGrant(room: request.roomName ?? "test-room", roomJoin: true) let token = try tokenGenerator.sign() diff --git a/Tests/LiveKitTests/Support/Room.swift b/Tests/LiveKitTests/Support/Room.swift index 0d4dada5c..95490ba36 100644 --- a/Tests/LiveKitTests/Support/Room.swift +++ b/Tests/LiveKitTests/Support/Room.swift @@ -79,12 +79,12 @@ 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/LiveKitTests/Support/TokenGenerator.swift b/Tests/LiveKitTests/Support/TokenGenerator.swift index db304b27d..ccd22ed8c 100644 --- a/Tests/LiveKitTests/Support/TokenGenerator.swift +++ b/Tests/LiveKitTests/Support/TokenGenerator.swift @@ -16,83 +16,9 @@ import Foundation import JWTKit - -public struct VideoGrant: Codable, Equatable { - /** name of the room, must be set for admin or join permissions */ - let room: String? - /** permission to create a room */ - let roomCreate: Bool? - /** permission to join a room as a participant, room must be set */ - let roomJoin: Bool? - /** permission to list rooms */ - let roomList: Bool? - /** permission to start a recording */ - let roomRecord: Bool? - /** permission to control a specific room, room must be set */ - let roomAdmin: Bool? - - /** - * allow participant to publish. If neither canPublish or canSubscribe is set, - * both publish and subscribe are enabled - */ - let canPublish: Bool? - /** allow participant to subscribe to other tracks */ - let canSubscribe: Bool? - /** - * allow participants to publish data, defaults to true if not set - */ - let canPublishData: Bool? - /** allowed sources for publishing */ - let canPublishSources: [String]? // String as returned in the JWT - /** participant isn't visible to others */ - let hidden: Bool? - /** participant is recording the room, when set, allows room to indicate it's being recorded */ - let recorder: Bool? - - 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 static let defaultTTL: TimeInterval = 30 * 60 @@ -104,7 +30,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 +53,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) } From aa2f08cb092fe3a43245c214b4bdf05206c01c39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Fri, 19 Sep 2025 11:11:02 +0200 Subject: [PATCH 20/32] Filter --- Sources/LiveKit/Auth/Sandbox.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/LiveKit/Auth/Sandbox.swift b/Sources/LiveKit/Auth/Sandbox.swift index 2e7b5a6b5..e090d625a 100644 --- a/Sources/LiveKit/Auth/Sandbox.swift +++ b/Sources/LiveKit/Auth/Sandbox.swift @@ -30,6 +30,6 @@ public struct Sandbox: TokenEndpoint { /// Initialize with a sandbox ID from LiveKit Cloud. public init(id: String) { - self.id = id.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + self.id = id.trimmingCharacters(in: .alphanumerics.inverted) } } From 86732966d5ae564b5f5955ae7cca0fe763bdf75b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Wed, 24 Sep 2025 15:11:54 +0200 Subject: [PATCH 21/32] Keys --- Sources/LiveKit/Auth/TokenSource.swift | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/Sources/LiveKit/Auth/TokenSource.swift b/Sources/LiveKit/Auth/TokenSource.swift index 866f2e176..82d552bb8 100644 --- a/Sources/LiveKit/Auth/TokenSource.swift +++ b/Sources/LiveKit/Auth/TokenSource.swift @@ -16,8 +16,6 @@ import Foundation -#warning("Fix camel case after deploying backend") - // MARK: - Token /// `Token` represent the credentials needed for connecting to a new Room. @@ -40,14 +38,14 @@ public enum Token { /// - SeeAlso: [Room Configuration Documentation](https://docs.livekit.io/home/get-started/authentication/#room-configuration) for more info. public 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_configuration" - // } + 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_configuration" + } public init( roomName: String? = nil, @@ -74,8 +72,8 @@ public enum Token { public let participantToken: String enum CodingKeys: String, CodingKey { - case serverURL = "serverUrl" - case participantToken + case serverURL = "server_url" + case participantToken = "participant_token" } public init(serverURL: URL, participantToken: String) { From 748ddd654c544a874eda4dac36c51d1babd4f321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Wed, 24 Sep 2025 15:13:21 +0200 Subject: [PATCH 22/32] Mutable variant with setRequest --- Sources/LiveKit/Auth/Sandbox.swift | 9 ++++ Sources/LiveKit/Auth/TokenSource.swift | 63 ++++++++++++++++---------- Sources/LiveKit/Core/Room.swift | 3 +- 3 files changed, 50 insertions(+), 25 deletions(-) diff --git a/Sources/LiveKit/Auth/Sandbox.swift b/Sources/LiveKit/Auth/Sandbox.swift index e090d625a..752daf41d 100644 --- a/Sources/LiveKit/Auth/Sandbox.swift +++ b/Sources/LiveKit/Auth/Sandbox.swift @@ -25,6 +25,15 @@ public struct Sandbox: TokenEndpoint { ["X-Sandbox-ID": id] } + public var request: Token.Request? + public mutating func setRequest(_ request: Token.Request) { + self.request = request + } + + public mutating func clearRequest() { + request = nil + } + /// The sandbox ID provided by LiveKit Cloud. public let id: String diff --git a/Sources/LiveKit/Auth/TokenSource.swift b/Sources/LiveKit/Auth/TokenSource.swift index 82d552bb8..66c803806 100644 --- a/Sources/LiveKit/Auth/TokenSource.swift +++ b/Sources/LiveKit/Auth/TokenSource.swift @@ -82,7 +82,6 @@ public enum Token { } } - public typealias Options = Request public typealias Literal = Response } @@ -91,18 +90,22 @@ public enum Token { /// Protocol for types that can provide connection credentials. /// Implement this protocol to create custom credential providers (e.g., fetching from your backend API). public protocol TokenSource: Sendable { - /// Fetch connection credentials for the given request. - /// - Parameter request: The token request containing room and participant information + var request: Token.Request? { get async } + mutating func setRequest(_ request: Token.Request) async + mutating func clearRequest() async + /// Get connection credentials for the given request. /// - Returns: A token response containing the server URL and participant token /// - Throws: An error if the token generation fails - func fetch(_ request: Token.Request) async throws -> Token.Response + func generate() async throws -> Token.Response } /// `Token.Literal` contains a single set of credentials, hard-coded or acquired from a static source. extension Token.Literal: TokenSource { - public func fetch(_: Token.Request) async throws -> Token.Response { - self - } + public var request: Token.Request? { nil } + public func setRequest(_: Token.Request) {} + public func clearRequest() {} + + public func generate() async throws -> Token.Response { self } } // MARK: - Endpoint @@ -123,17 +126,19 @@ public extension TokenEndpoint { var method: String { "POST" } var headers: [String: String] { [:] } - func fetch(_ request: Token.Request) async throws -> Token.Response { + func generate() async throws -> Token.Response { var urlRequest = URLRequest(url: url) urlRequest.httpMethod = method for (key, value) in headers { urlRequest.addValue(value, forHTTPHeaderField: key) } - urlRequest.httpBody = try JSONEncoder().encode(request) + urlRequest.httpBody = try await JSONEncoder().encode(request) let (data, response) = try await URLSession.shared.data(for: urlRequest) + try Task.checkCancellation() + guard let httpResponse = response as? HTTPURLResponse else { throw LiveKitError(.network, message: "Error generating token from the token server, no response") } @@ -159,7 +164,20 @@ public actor CachingTokenSource: TokenSource, Loggable { /// - Returns: `true` if the cached credentials are still valid, `false` otherwise public typealias TokenValidator = (Token.Request, Token.Response) -> Bool - private let source: TokenSource + public var request: Token.Request? { + get async { await source.request } + } + + public func setRequest(_ request: Token.Request) async { + await source.setRequest(request) + } + + public func clearRequest() async { + await source.clearRequest() + await store.clear() + } + + private var source: TokenSource private let store: TokenStore private let validator: TokenValidator @@ -178,9 +196,10 @@ public actor CachingTokenSource: TokenSource, Loggable { self.validator = validator } - public func fetch(_ request: Token.Request) async throws -> Token.Response { - if let (cachedRequest, cachedResponse) = await store.retrieve(), - cachedRequest == request, + public func generate() async throws -> Token.Response { + let request = await request ?? .init() + + if let (cachedRequest, cachedResponse) = await store.retrieve(), cachedRequest == request, validator(cachedRequest, cachedResponse) { log("Using cached credentials", .debug) @@ -188,20 +207,18 @@ public actor CachingTokenSource: TokenSource, Loggable { } log("Requesting new credentials", .debug) - let response = try await source.fetch(request) + let response = try await source.generate() + + guard validator(request, response) else { + throw LiveKitError(.network, message: "Invalid credentials") + } + await store.store((request, response)) return response } - /// 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 token if found, nil otherwise - public func cachedToken() async -> Token.Response? { - await store.retrieve()?.1 + var cachedResponse: Token.Response? { + get async { await store.retrieve()?.1 } } } diff --git a/Sources/LiveKit/Core/Room.swift b/Sources/LiveKit/Core/Room.swift index 56598b864..2690de339 100644 --- a/Sources/LiveKit/Core/Room.swift +++ b/Sources/LiveKit/Core/Room.swift @@ -426,13 +426,12 @@ public class Room: NSObject, @unchecked Sendable, ObservableObject, Loggable { } public func connect(tokenSource: TokenSource, - tokenOptions: Token.Options = .init(), connectOptions: ConnectOptions? = nil, roomOptions: RoomOptions? = nil) async throws { self.tokenSource = tokenSource - let token = try await tokenSource.fetch(tokenOptions) + let token = try await tokenSource.generate() try await connect(url: token.serverURL.absoluteString, token: token.participantToken, connectOptions: connectOptions, roomOptions: roomOptions) } From 0c89008d5a6e8c42aa43945c5e833fa283cd1da2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Thu, 25 Sep 2025 09:16:15 +0200 Subject: [PATCH 23/32] Revert "Mutable variant with setRequest" This reverts commit 6b47bb2fb73b59c7563480009dd0f9363361a0b9. --- Sources/LiveKit/Auth/Sandbox.swift | 9 ---- Sources/LiveKit/Auth/TokenSource.swift | 63 ++++++++++---------------- Sources/LiveKit/Core/Room.swift | 3 +- 3 files changed, 25 insertions(+), 50 deletions(-) diff --git a/Sources/LiveKit/Auth/Sandbox.swift b/Sources/LiveKit/Auth/Sandbox.swift index 752daf41d..e090d625a 100644 --- a/Sources/LiveKit/Auth/Sandbox.swift +++ b/Sources/LiveKit/Auth/Sandbox.swift @@ -25,15 +25,6 @@ public struct Sandbox: TokenEndpoint { ["X-Sandbox-ID": id] } - public var request: Token.Request? - public mutating func setRequest(_ request: Token.Request) { - self.request = request - } - - public mutating func clearRequest() { - request = nil - } - /// The sandbox ID provided by LiveKit Cloud. public let id: String diff --git a/Sources/LiveKit/Auth/TokenSource.swift b/Sources/LiveKit/Auth/TokenSource.swift index 66c803806..82d552bb8 100644 --- a/Sources/LiveKit/Auth/TokenSource.swift +++ b/Sources/LiveKit/Auth/TokenSource.swift @@ -82,6 +82,7 @@ public enum Token { } } + public typealias Options = Request public typealias Literal = Response } @@ -90,22 +91,18 @@ public enum Token { /// Protocol for types that can provide connection credentials. /// Implement this protocol to create custom credential providers (e.g., fetching from your backend API). public protocol TokenSource: Sendable { - var request: Token.Request? { get async } - mutating func setRequest(_ request: Token.Request) async - mutating func clearRequest() async - /// Get connection credentials for the given request. + /// Fetch connection credentials for the given request. + /// - Parameter request: The token request containing room and participant information /// - Returns: A token response containing the server URL and participant token /// - Throws: An error if the token generation fails - func generate() async throws -> Token.Response + func fetch(_ request: Token.Request) async throws -> Token.Response } /// `Token.Literal` contains a single set of credentials, hard-coded or acquired from a static source. extension Token.Literal: TokenSource { - public var request: Token.Request? { nil } - public func setRequest(_: Token.Request) {} - public func clearRequest() {} - - public func generate() async throws -> Token.Response { self } + public func fetch(_: Token.Request) async throws -> Token.Response { + self + } } // MARK: - Endpoint @@ -126,19 +123,17 @@ public extension TokenEndpoint { var method: String { "POST" } var headers: [String: String] { [:] } - func generate() async throws -> Token.Response { + func fetch(_ request: Token.Request) async throws -> Token.Response { var urlRequest = URLRequest(url: url) urlRequest.httpMethod = method for (key, value) in headers { urlRequest.addValue(value, forHTTPHeaderField: key) } - urlRequest.httpBody = try await JSONEncoder().encode(request) + urlRequest.httpBody = try JSONEncoder().encode(request) let (data, response) = try await URLSession.shared.data(for: urlRequest) - try Task.checkCancellation() - guard let httpResponse = response as? HTTPURLResponse else { throw LiveKitError(.network, message: "Error generating token from the token server, no response") } @@ -164,20 +159,7 @@ public actor CachingTokenSource: TokenSource, Loggable { /// - Returns: `true` if the cached credentials are still valid, `false` otherwise public typealias TokenValidator = (Token.Request, Token.Response) -> Bool - public var request: Token.Request? { - get async { await source.request } - } - - public func setRequest(_ request: Token.Request) async { - await source.setRequest(request) - } - - public func clearRequest() async { - await source.clearRequest() - await store.clear() - } - - private var source: TokenSource + private let source: TokenSource private let store: TokenStore private let validator: TokenValidator @@ -196,10 +178,9 @@ public actor CachingTokenSource: TokenSource, Loggable { self.validator = validator } - public func generate() async throws -> Token.Response { - let request = await request ?? .init() - - if let (cachedRequest, cachedResponse) = await store.retrieve(), cachedRequest == request, + public func fetch(_ request: Token.Request) async throws -> Token.Response { + if let (cachedRequest, cachedResponse) = await store.retrieve(), + cachedRequest == request, validator(cachedRequest, cachedResponse) { log("Using cached credentials", .debug) @@ -207,18 +188,20 @@ public actor CachingTokenSource: TokenSource, Loggable { } log("Requesting new credentials", .debug) - let response = try await source.generate() - - guard validator(request, response) else { - throw LiveKitError(.network, message: "Invalid credentials") - } - + let response = try await source.fetch(request) await store.store((request, response)) return response } - var cachedResponse: Token.Response? { - get async { await store.retrieve()?.1 } + /// 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 token if found, nil otherwise + public func cachedToken() async -> Token.Response? { + await store.retrieve()?.1 } } diff --git a/Sources/LiveKit/Core/Room.swift b/Sources/LiveKit/Core/Room.swift index 2690de339..56598b864 100644 --- a/Sources/LiveKit/Core/Room.swift +++ b/Sources/LiveKit/Core/Room.swift @@ -426,12 +426,13 @@ public class Room: NSObject, @unchecked Sendable, ObservableObject, Loggable { } public func connect(tokenSource: TokenSource, + tokenOptions: Token.Options = .init(), connectOptions: ConnectOptions? = nil, roomOptions: RoomOptions? = nil) async throws { self.tokenSource = tokenSource - let token = try await tokenSource.generate() + let token = try await tokenSource.fetch(tokenOptions) try await connect(url: token.serverURL.absoluteString, token: token.participantToken, connectOptions: connectOptions, roomOptions: roomOptions) } From 22f8d7713a86fb6312a466cf394631d2cb3bcb5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Thu, 2 Oct 2025 13:14:07 +0200 Subject: [PATCH 24/32] Remove Room integration --- Sources/LiveKit/Core/Room.swift | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/Sources/LiveKit/Core/Room.swift b/Sources/LiveKit/Core/Room.swift index 56598b864..ba1b4de46 100644 --- a/Sources/LiveKit/Core/Room.swift +++ b/Sources/LiveKit/Core/Room.swift @@ -82,9 +82,6 @@ public class Room: NSObject, @unchecked Sendable, ObservableObject, Loggable { @objc public var publishersCount: Int { _state.numPublishers } - // Credentials - public var tokenSource: (any TokenSource)? - // expose engine's vars @objc public var url: String? { _state.url?.absoluteString } @@ -425,17 +422,6 @@ public class Room: NSObject, @unchecked Sendable, ObservableObject, Loggable { log("Connected to \(String(describing: self))", .info) } - public func connect(tokenSource: TokenSource, - tokenOptions: Token.Options = .init(), - connectOptions: ConnectOptions? = nil, - roomOptions: RoomOptions? = nil) async throws - { - self.tokenSource = tokenSource - - let token = try await tokenSource.fetch(tokenOptions) - try await connect(url: token.serverURL.absoluteString, token: token.participantToken, connectOptions: connectOptions, roomOptions: roomOptions) - } - @objc public func disconnect() async { let shouldDisconnect = _state.mutate { From eea7c2696bfac3868e20b42e76349198d12d497e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Thu, 2 Oct 2025 14:19:02 +0200 Subject: [PATCH 25/32] WIP --- Sources/LiveKit/Auth/TokenSource.swift | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/Sources/LiveKit/Auth/TokenSource.swift b/Sources/LiveKit/Auth/TokenSource.swift index 82d552bb8..5e4448121 100644 --- a/Sources/LiveKit/Auth/TokenSource.swift +++ b/Sources/LiveKit/Auth/TokenSource.swift @@ -88,19 +88,16 @@ public enum Token { // MARK: - Source -/// Protocol for types that can provide connection credentials. -/// Implement this protocol to create custom credential providers (e.g., fetching from your backend API). -public protocol TokenSource: Sendable { - /// Fetch connection credentials for the given request. - /// - Parameter request: The token request containing room and participant information - /// - Returns: A token response containing the server URL and participant token - /// - Throws: An error if the token generation fails +public protocol TokenSourceFixed: Sendable { + func fetch() async throws -> Token.Response +} + +public protocol TokenSourceConfigurable: Sendable { func fetch(_ request: Token.Request) async throws -> Token.Response } -/// `Token.Literal` contains a single set of credentials, hard-coded or acquired from a static source. -extension Token.Literal: TokenSource { - public func fetch(_: Token.Request) async throws -> Token.Response { +extension Token.Literal: TokenSourceFixed { + public func fetch() async throws -> Token.Response { self } } From b57e45413f71d7c78351379611427cf55a7c27ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Fri, 3 Oct 2025 13:11:37 +0200 Subject: [PATCH 26/32] Move, fix serialization --- Sources/LiveKit/Auth/TokenSource.swift | 266 ------------------ .../LiveKit/Token/CachingTokenSource.swift | 115 ++++++++ .../LiveKit/Token/EndpointTokenSource.swift | 56 ++++ Sources/LiveKit/{Auth => Token}/JWT.swift | 0 .../LiveKit/Token/LiteralTokenSource.swift | 35 +++ .../SandboxTokenSource.swift} | 8 +- Sources/LiveKit/Token/TokenSource.swift | 132 +++++++++ Sources/LiveKit/Types/RoomConfiguration.swift | 2 + 8 files changed, 344 insertions(+), 270 deletions(-) delete mode 100644 Sources/LiveKit/Auth/TokenSource.swift create mode 100644 Sources/LiveKit/Token/CachingTokenSource.swift create mode 100644 Sources/LiveKit/Token/EndpointTokenSource.swift rename Sources/LiveKit/{Auth => Token}/JWT.swift (100%) create mode 100644 Sources/LiveKit/Token/LiteralTokenSource.swift rename Sources/LiveKit/{Auth/Sandbox.swift => Token/SandboxTokenSource.swift} (76%) create mode 100644 Sources/LiveKit/Token/TokenSource.swift diff --git a/Sources/LiveKit/Auth/TokenSource.swift b/Sources/LiveKit/Auth/TokenSource.swift deleted file mode 100644 index 5e4448121..000000000 --- a/Sources/LiveKit/Auth/TokenSource.swift +++ /dev/null @@ -1,266 +0,0 @@ -/* - * 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: - Token - -/// `Token` represent the credentials needed for connecting to a new Room. -/// - SeeAlso: [LiveKit's Authentication Documentation](https://docs.livekit.io/home/get-started/authentication/) for more information. -public enum Token { - /// Request parameters for generating connection credentials. - public struct Request: Encodable, Sendable, Equatable { - /// The name of the room being requested when generating credentials. - public let roomName: String? - /// The name of the participant being requested for this client when generating credentials. - public let participantName: String? - /// The identity of the participant being requested for this client when generating credentials. - public let participantIdentity: String? - /// Any participant metadata being included along with the credentials generation operation. - public let participantMetadata: String? - /// Any participant attributes being included along with the credentials generation operation. - public let participantAttributes: [String: String]? - /// A `RoomConfiguration` object can be passed to request extra parameters when generating connection credentials. - /// Used for advanced room configuration like dispatching agents, setting room limits, etc. - /// - SeeAlso: [Room Configuration Documentation](https://docs.livekit.io/home/get-started/authentication/#room-configuration) for more info. - public 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_configuration" - } - - public init( - roomName: String? = nil, - participantName: String? = nil, - participantIdentity: String? = nil, - participantMetadata: String? = nil, - participantAttributes: [String: String]? = nil, - roomConfiguration: RoomConfiguration? = nil - ) { - self.roomName = roomName - self.participantName = participantName - self.participantIdentity = participantIdentity - self.participantMetadata = participantMetadata - self.participantAttributes = participantAttributes - self.roomConfiguration = roomConfiguration - } - } - - /// Response containing the credentials needed to connect to a room. - public struct Response: Decodable, Sendable { - /// The WebSocket URL for the LiveKit server. - public let serverURL: URL - /// The JWT token containing participant permissions and metadata. - public let participantToken: String - - enum CodingKeys: String, CodingKey { - case serverURL = "server_url" - case participantToken = "participant_token" - } - - public init(serverURL: URL, participantToken: String) { - self.serverURL = serverURL - self.participantToken = participantToken - } - } - - public typealias Options = Request - public typealias Literal = Response -} - -// MARK: - Source - -public protocol TokenSourceFixed: Sendable { - func fetch() async throws -> Token.Response -} - -public protocol TokenSourceConfigurable: Sendable { - func fetch(_ request: Token.Request) async throws -> Token.Response -} - -extension Token.Literal: TokenSourceFixed { - public func fetch() async throws -> Token.Response { - self - } -} - -// MARK: - Endpoint - -/// 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. -/// - Note: The response is expected to be a `Token.Response` object. -public protocol TokenEndpoint: TokenSource { - /// The URL endpoint for token generation. - var url: URL { get } - /// The HTTP method to use (defaults to "POST"). - var method: String { get } - /// Additional HTTP headers to include with the request. - var headers: [String: String] { get } -} - -public extension TokenEndpoint { - var method: String { "POST" } - var headers: [String: String] { [:] } - - func fetch(_ request: Token.Request) async throws -> Token.Response { - var urlRequest = URLRequest(url: url) - - urlRequest.httpMethod = method - for (key, value) in headers { - urlRequest.addValue(value, forHTTPHeaderField: key) - } - urlRequest.httpBody = try JSONEncoder().encode(request) - - 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(Token.Response.self, from: data) - } -} - -// MARK: - Cache - -/// `CachingTokenSource` handles caching of credentials from any other `TokenSource` using configurable store. -public actor CachingTokenSource: TokenSource, Loggable { - /// A tuple containing the request and response that were cached. - public typealias Cached = (Token.Request, Token.Response) - /// A closure that validates whether cached credentials are still valid. - /// - Parameters: - /// - request: The original token request - /// - response: The cached token response - /// - Returns: `true` if the cached credentials are still valid, `false` otherwise - public typealias TokenValidator = (Token.Request, Token.Response) -> Bool - - private let source: TokenSource - private let store: TokenStore - private let validator: TokenValidator - - /// Initialize a caching wrapper around any credentials provider. - /// - Parameters: - /// - source: The underlying token source to wrap - /// - 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: TokenSource, - store: TokenStore = InMemoryTokenStore(), - validator: @escaping TokenValidator = { _, response in response.hasValidToken() } - ) { - self.source = source - self.store = store - self.validator = validator - } - - public func fetch(_ request: Token.Request) async throws -> Token.Response { - if let (cachedRequest, cachedResponse) = await store.retrieve(), - cachedRequest == request, - validator(cachedRequest, cachedResponse) - { - log("Using cached credentials", .debug) - return cachedResponse - } - - log("Requesting new credentials", .debug) - let response = try await source.fetch(request) - await store.store((request, response)) - return response - } - - /// 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 token if found, nil otherwise - public func cachedToken() async -> Token.Response? { - await store.retrieve()?.1 - } -} - -// MARK: - Store - -/// Protocol for abstract store that can persist and retrieve a single cached credential pair. -/// Implement this protocol to create custom store implementations e.g. for Keychain. -public protocol TokenStore: Sendable { - /// Store credentials in the store (replaces any existing credentials) - func store(_ credentials: CachingTokenSource.Cached) async - - /// Retrieve the cached credentials - /// - Returns: The cached credentials if found, nil otherwise - func retrieve() async -> CachingTokenSource.Cached? - - /// Clear the stored credentials - func clear() async -} - -/// Simple in-memory store implementation -public actor InMemoryTokenStore: TokenStore { - 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 Token.Response { - /// Validates whether the JWT token is still valid. - /// - 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 found, nil otherwise - func jwt() -> LiveKitJWTPayload? { - LiveKitJWTPayload.fromUnverified(token: participantToken) - } -} diff --git a/Sources/LiveKit/Token/CachingTokenSource.swift b/Sources/LiveKit/Token/CachingTokenSource.swift new file mode 100644 index 000000000..d02d6fe87 --- /dev/null +++ b/Sources/LiveKit/Token/CachingTokenSource.swift @@ -0,0 +1,115 @@ +/* + * 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 + +/// `CachingTokenSource` handles caching of credentials from any other `TokenSource` using configurable store. +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. + /// - Parameters: + /// - request: The original token request + /// - response: The cached token response + /// - Returns: `true` if the cached credentials are still valid, `false` otherwise + public typealias TokenValidator = @Sendable (TokenRequestOptions, TokenSourceResponse) -> Bool + + private let source: TokenSourceConfigurable + private let store: TokenStore + private let validator: TokenValidator + + /// Initialize a caching wrapper around any credentials provider. + /// - Parameters: + /// - source: The underlying token source to wrap + /// - 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: TokenStore = InMemoryTokenStore(), + validator: @escaping TokenValidator = { _, 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 response = try await source.fetch(options) + await store.store((options, response)) + return response + } + + /// 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 token if found, nil otherwise + public func cachedToken() async -> TokenSourceResponse? { + await store.retrieve()?.1 + } +} + +public extension TokenSourceConfigurable { + func cached(store: TokenStore = InMemoryTokenStore(), validator: @escaping CachingTokenSource.TokenValidator = { _, response in response.hasValidToken() }) -> CachingTokenSource { + CachingTokenSource(self, store: store, validator: validator) + } +} + +// MARK: - Store + +/// Protocol for abstract store that can persist and retrieve a single cached credential pair. +/// Implement this protocol to create custom store implementations e.g. for Keychain. +public protocol TokenStore: Sendable { + /// Store credentials in the store (replaces any existing credentials) + func store(_ credentials: CachingTokenSource.Cached) async + + /// Retrieve the cached credentials + /// - Returns: The cached credentials if found, nil otherwise + func retrieve() async -> CachingTokenSource.Cached? + + /// Clear the stored credentials + func clear() async +} + +/// Simple in-memory store implementation +public actor InMemoryTokenStore: TokenStore { + 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 + } +} diff --git a/Sources/LiveKit/Token/EndpointTokenSource.swift b/Sources/LiveKit/Token/EndpointTokenSource.swift new file mode 100644 index 000000000..7aaa141dc --- /dev/null +++ b/Sources/LiveKit/Token/EndpointTokenSource.swift @@ -0,0 +1,56 @@ +/* + * 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. +/// - Note: The response is expected to be a `Token.Response` object. +public protocol EndpointTokenSource: TokenSourceConfigurable { + /// The URL endpoint for token generation. + var url: URL { get } + /// The HTTP method to use (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) + + 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/Auth/JWT.swift b/Sources/LiveKit/Token/JWT.swift similarity index 100% rename from Sources/LiveKit/Auth/JWT.swift rename to Sources/LiveKit/Token/JWT.swift diff --git a/Sources/LiveKit/Token/LiteralTokenSource.swift b/Sources/LiveKit/Token/LiteralTokenSource.swift new file mode 100644 index 000000000..e8dacc724 --- /dev/null +++ b/Sources/LiveKit/Token/LiteralTokenSource.swift @@ -0,0 +1,35 @@ +/* + * 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 LiteralTokenSource: TokenSourceFixed { + let serverURL: URL + let participantToken: String + let participantName: String? + let roomName: String? + + public init(serverURL: URL, participantToken: String, participantName: String? = nil, roomName: String? = nil) { + self.serverURL = serverURL + self.participantToken = participantToken + self.participantName = participantName + self.roomName = roomName + } + + public func fetch() async throws -> TokenSourceResponse { + TokenSourceResponse(serverURL: serverURL, participantToken: participantToken, participantName: participantName, roomName: roomName) + } +} diff --git a/Sources/LiveKit/Auth/Sandbox.swift b/Sources/LiveKit/Token/SandboxTokenSource.swift similarity index 76% rename from Sources/LiveKit/Auth/Sandbox.swift rename to Sources/LiveKit/Token/SandboxTokenSource.swift index e090d625a..b0d0d42c6 100644 --- a/Sources/LiveKit/Auth/Sandbox.swift +++ b/Sources/LiveKit/Token/SandboxTokenSource.swift @@ -16,11 +16,11 @@ import Foundation -/// `Sandbox` queries LiveKit Sandbox token server for credentials, +/// `SandboxTokenSource` queries LiveKit Sandbox [token server](https://cloud.livekit.io/projects/p_/sandbox/templates/token-server) for credentials, /// which supports quick prototyping/getting started types of use cases. -/// - Warning: This token endpoint is **INSECURE** and should **NOT** be used in production. -public struct Sandbox: TokenEndpoint { - public let url = URL(string: "https://cloud-api.livekit.io/api/sandbox/connection-details")! +/// - Warning: This token source is **INSECURE** and should **NOT** be used in production. +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] } diff --git a/Sources/LiveKit/Token/TokenSource.swift b/Sources/LiveKit/Token/TokenSource.swift new file mode 100644 index 000000000..d89e7fb4e --- /dev/null +++ b/Sources/LiveKit/Token/TokenSource.swift @@ -0,0 +1,132 @@ +/* + * 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: - Token + +/// Request parameters for generating connection credentials. +public struct TokenRequestOptions: Encodable, Sendable, Equatable { + /// The name of the room being requested when generating credentials. + public let roomName: String? + /// The name of the participant being requested for this client when generating credentials. + public let participantName: String? + /// The identity of the participant being requested for this client when generating credentials. + public let participantIdentity: String? + /// Any participant metadata being included along with the credentials generation operation. + public let participantMetadata: String? + /// Any participant attributes being included along with the credentials generation operation. + public let participantAttributes: [String: String]? + /// A `RoomConfiguration` object can be passed to request extra parameters when generating connection credentials. + /// Used for advanced room configuration like dispatching agents, setting room limits, etc. + /// - SeeAlso: [Room Configuration Documentation](https://docs.livekit.io/home/get-started/authentication/#room-configuration) for more info. + public 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" + } + + public init( + roomName: String? = nil, + participantName: String? = nil, + participantIdentity: String? = nil, + participantMetadata: String? = nil, + participantAttributes: [String: String]? = nil, + roomConfiguration: RoomConfiguration? = nil + ) { + self.roomName = roomName + self.participantName = participantName + self.participantIdentity = participantIdentity + self.participantMetadata = participantMetadata + self.participantAttributes = participantAttributes + self.roomConfiguration = roomConfiguration + } +} + +/// Response containing the credentials needed to connect to a room. +public struct TokenSourceResponse: Decodable, Sendable { + /// The WebSocket URL for the LiveKit server. + public let serverURL: URL + /// The JWT token containing participant permissions and metadata. + public let participantToken: String + /// The name of the participant being requested for this client when generating credentials. + public let participantName: String? + /// The name of the room being requested when generating credentials. + 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 + } +} + +// MARK: - Source + +public protocol TokenSourceFixed: Sendable { + func fetch() async throws -> TokenSourceResponse +} + +public protocol TokenSourceConfigurable: Sendable { + func fetch(_ options: TokenRequestOptions) async throws -> TokenSourceResponse +} + +extension TokenSourceResponse: TokenSourceFixed { + public func fetch() async throws -> TokenSourceResponse { + self + } +} + +// MARK: - Validation + +public extension TokenSourceResponse { + /// Validates whether the JWT token is still valid. + /// - 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 found, nil otherwise + func jwt() -> LiveKitJWTPayload? { + LiveKitJWTPayload.fromUnverified(token: participantToken) + } +} diff --git a/Sources/LiveKit/Types/RoomConfiguration.swift b/Sources/LiveKit/Types/RoomConfiguration.swift index 6e54a768b..bbf197f6d 100644 --- a/Sources/LiveKit/Types/RoomConfiguration.swift +++ b/Sources/LiveKit/Types/RoomConfiguration.swift @@ -32,6 +32,8 @@ public struct RoomConfiguration: Encodable, Sendable, Equatable { /// Metadata of room public let metadata: String? + // Egress configuration ommited, due to complex serialization + /// Minimum playout delay of subscriber public let minPlayoutDelay: UInt32? From 71fe3dd6cd47a8c1ecd9ebe3a0be82fb55ab4d5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Fri, 3 Oct 2025 13:16:25 +0200 Subject: [PATCH 27/32] Fix tests --- .../LiveKitTests/Auth/TokenSourceTests.swift | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/Tests/LiveKitTests/Auth/TokenSourceTests.swift b/Tests/LiveKitTests/Auth/TokenSourceTests.swift index 37841af17..855a4a3f3 100644 --- a/Tests/LiveKitTests/Auth/TokenSourceTests.swift +++ b/Tests/LiveKitTests/Auth/TokenSourceTests.swift @@ -19,7 +19,7 @@ import Foundation import XCTest class TokenSourceTests: LKTestCase { - actor MockValidJWTSource: TokenSource { + actor MockValidJWTSource: TokenSourceConfigurable { let serverURL = URL(string: "wss://test.livekit.io")! let participantName: String var callCount = 0 @@ -28,59 +28,59 @@ class TokenSourceTests: LKTestCase { self.participantName = participantName } - func fetch(_ request: Token.Request) async throws -> Token.Response { + func fetch(_ options: TokenRequestOptions) async throws -> TokenSourceResponse { callCount += 1 let tokenGenerator = TokenGenerator( apiKey: "test-api-key", apiSecret: "test-api-secret", - identity: request.participantIdentity ?? "test-identity" + identity: options.participantIdentity ?? "test-identity" ) - tokenGenerator.name = request.participantName ?? participantName - tokenGenerator.videoGrant = LiveKitJWTPayload.VideoGrant(room: request.roomName ?? "test-room", roomJoin: true) + tokenGenerator.name = options.participantName ?? participantName + tokenGenerator.videoGrant = LiveKitJWTPayload.VideoGrant(room: options.roomName ?? "test-room", roomJoin: true) let token = try tokenGenerator.sign() - return Token.Response( + return TokenSourceResponse( serverURL: serverURL, participantToken: token ) } } - actor MockInvalidJWTSource: TokenSource { + actor MockInvalidJWTSource: TokenSourceConfigurable { let serverURL = URL(string: "wss://test.livekit.io")! var callCount = 0 - func fetch(_: Token.Request) async throws -> Token.Response { + func fetch(_: TokenRequestOptions) async throws -> TokenSourceResponse { callCount += 1 - return Token.Response( + return TokenSourceResponse( serverURL: serverURL, participantToken: "invalid.jwt.token" ) } } - actor MockExpiredJWTSource: TokenSource { + actor MockExpiredJWTSource: TokenSourceConfigurable { let serverURL = URL(string: "wss://test.livekit.io")! var callCount = 0 - func fetch(_ request: Token.Request) async throws -> Token.Response { + func fetch(_ options: TokenRequestOptions) async throws -> TokenSourceResponse { callCount += 1 let tokenGenerator = TokenGenerator( apiKey: "test-api-key", apiSecret: "test-api-secret", - identity: request.participantIdentity ?? "test-identity", + identity: options.participantIdentity ?? "test-identity", ttl: -60 ) - tokenGenerator.name = request.participantName ?? "test-participant" - tokenGenerator.videoGrant = LiveKitJWTPayload.VideoGrant(room: request.roomName ?? "test-room", roomJoin: true) + tokenGenerator.name = options.participantName ?? "test-participant" + tokenGenerator.videoGrant = LiveKitJWTPayload.VideoGrant(room: options.roomName ?? "test-room", roomJoin: true) let token = try tokenGenerator.sign() - return Token.Response( + return TokenSourceResponse( serverURL: serverURL, participantToken: token ) @@ -91,7 +91,7 @@ class TokenSourceTests: LKTestCase { let mockSource = MockValidJWTSource(participantName: "alice") let cachingSource = CachingTokenSource(mockSource) - let request = Token.Request( + let request = TokenRequestOptions( roomName: "test-room", participantName: "alice", participantIdentity: "alice-id" @@ -109,7 +109,7 @@ class TokenSourceTests: LKTestCase { XCTAssertEqual(response2.participantToken, response1.participantToken) XCTAssertEqual(response2.serverURL, response1.serverURL) - let differentRequest = Token.Request( + let differentRequest = TokenRequestOptions( roomName: "different-room", participantName: "alice", participantIdentity: "alice-id" @@ -129,7 +129,7 @@ class TokenSourceTests: LKTestCase { let mockInvalidSource = MockInvalidJWTSource() let cachingSource = CachingTokenSource(mockInvalidSource) - let request = Token.Request( + let request = TokenRequestOptions( roomName: "test-room", participantName: "bob", participantIdentity: "bob-id" @@ -167,7 +167,7 @@ class TokenSourceTests: LKTestCase { let cachingSource = CachingTokenSource(mockSource, validator: customValidator) - let charlieRequest = Token.Request( + let charlieRequest = TokenRequestOptions( roomName: "test-room", participantName: "charlie", participantIdentity: "charlie-id" @@ -183,7 +183,7 @@ class TokenSourceTests: LKTestCase { XCTAssertEqual(callCount2, 1) XCTAssertEqual(response2.participantToken, response1.participantToken) - let aliceRequest = Token.Request( + let aliceRequest = TokenRequestOptions( roomName: "test-room", participantName: "alice", participantIdentity: "alice-id" @@ -204,7 +204,7 @@ class TokenSourceTests: LKTestCase { let tokenCachingSource = CachingTokenSource(tokenMockSource, validator: tokenContentValidator) - let roomRequest = Token.Request( + let roomRequest = TokenRequestOptions( roomName: "test-room", participantName: "dave", participantIdentity: "dave-id" @@ -218,7 +218,7 @@ class TokenSourceTests: LKTestCase { let tokenCallCount2 = await tokenMockSource.callCount XCTAssertEqual(tokenCallCount2, 1) - let differentRoomRequest = Token.Request( + let differentRoomRequest = TokenRequestOptions( roomName: "different-room", participantName: "dave", participantIdentity: "dave-id" @@ -237,7 +237,7 @@ class TokenSourceTests: LKTestCase { let mockSource = MockValidJWTSource(participantName: "concurrent-test") let cachingSource = CachingTokenSource(mockSource) - let request = Token.Request( + let request = TokenRequestOptions( roomName: "concurrent-room", participantName: "concurrent-user", participantIdentity: "concurrent-id" From 4bbdca89c6ef9faa3484c7cee8f6c9ac50dada62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Fri, 3 Oct 2025 13:27:26 +0200 Subject: [PATCH 28/32] Move --- .../LiveKit/Token/CachingTokenSource.swift | 28 ++++++++++ Sources/LiveKit/Token/TokenSource.swift | 54 ++++--------------- 2 files changed, 38 insertions(+), 44 deletions(-) diff --git a/Sources/LiveKit/Token/CachingTokenSource.swift b/Sources/LiveKit/Token/CachingTokenSource.swift index d02d6fe87..ee2694d84 100644 --- a/Sources/LiveKit/Token/CachingTokenSource.swift +++ b/Sources/LiveKit/Token/CachingTokenSource.swift @@ -113,3 +113,31 @@ public actor InMemoryTokenStore: TokenStore { cached = nil } } + +// MARK: - Validation + +public extension TokenSourceResponse { + /// Validates whether the JWT token is still valid. + /// - 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 found, nil otherwise + func jwt() -> LiveKitJWTPayload? { + LiveKitJWTPayload.fromUnverified(token: participantToken) + } +} diff --git a/Sources/LiveKit/Token/TokenSource.swift b/Sources/LiveKit/Token/TokenSource.swift index d89e7fb4e..708d0f832 100644 --- a/Sources/LiveKit/Token/TokenSource.swift +++ b/Sources/LiveKit/Token/TokenSource.swift @@ -16,6 +16,16 @@ import Foundation +// MARK: - Source + +public protocol TokenSourceFixed: Sendable { + func fetch() async throws -> TokenSourceResponse +} + +public protocol TokenSourceConfigurable: Sendable { + func fetch(_ options: TokenRequestOptions) async throws -> TokenSourceResponse +} + // MARK: - Token /// Request parameters for generating connection credentials. @@ -86,47 +96,3 @@ public struct TokenSourceResponse: Decodable, Sendable { self.roomName = roomName } } - -// MARK: - Source - -public protocol TokenSourceFixed: Sendable { - func fetch() async throws -> TokenSourceResponse -} - -public protocol TokenSourceConfigurable: Sendable { - func fetch(_ options: TokenRequestOptions) async throws -> TokenSourceResponse -} - -extension TokenSourceResponse: TokenSourceFixed { - public func fetch() async throws -> TokenSourceResponse { - self - } -} - -// MARK: - Validation - -public extension TokenSourceResponse { - /// Validates whether the JWT token is still valid. - /// - 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 found, nil otherwise - func jwt() -> LiveKitJWTPayload? { - LiveKitJWTPayload.fromUnverified(token: participantToken) - } -} From 6ce5ad81697c45141041f1a505e6f895f2ba3e61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Fri, 3 Oct 2025 14:01:42 +0200 Subject: [PATCH 29/32] Comments --- .../LiveKit/Token/CachingTokenSource.swift | 69 ++++++++++++------- .../LiveKit/Token/EndpointTokenSource.swift | 11 ++- Sources/LiveKit/Token/JWT.swift | 45 +++++++----- .../LiveKit/Token/LiteralTokenSource.swift | 20 ++++++ .../LiveKit/Token/SandboxTokenSource.swift | 14 ++-- Sources/LiveKit/Token/TokenSource.swift | 44 ++++++++---- 6 files changed, 142 insertions(+), 61 deletions(-) diff --git a/Sources/LiveKit/Token/CachingTokenSource.swift b/Sources/LiveKit/Token/CachingTokenSource.swift index ee2694d84..be4bf2259 100644 --- a/Sources/LiveKit/Token/CachingTokenSource.swift +++ b/Sources/LiveKit/Token/CachingTokenSource.swift @@ -16,30 +16,33 @@ import Foundation -/// `CachingTokenSource` handles caching of credentials from any other `TokenSource` using configurable store. +/// 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. - /// - Parameters: - /// - request: The original token request - /// - response: The cached token response - /// - Returns: `true` if the cached credentials are still valid, `false` otherwise - public typealias TokenValidator = @Sendable (TokenRequestOptions, TokenSourceResponse) -> Bool + /// + /// 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 private let source: TokenSourceConfigurable private let store: TokenStore - private let validator: TokenValidator + private let validator: Validator - /// Initialize a caching wrapper around any credentials provider. + /// Initialize a caching wrapper around any token source. + /// /// - Parameters: - /// - source: The underlying token source to wrap + /// - 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: TokenStore = InMemoryTokenStore(), - validator: @escaping TokenValidator = { _, response in response.hasValidToken() } + validator: @escaping Validator = { _, response in response.hasValidToken() } ) { self.source = source self.store = store @@ -56,9 +59,10 @@ public actor CachingTokenSource: TokenSourceConfigurable, Loggable { } log("Requesting new credentials", .debug) - let response = try await source.fetch(options) - await store.store((options, response)) - return response + 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. @@ -67,35 +71,50 @@ public actor CachingTokenSource: TokenSourceConfigurable, Loggable { } /// Get the cached credentials - /// - Returns: The cached token if found, nil otherwise - public func cachedToken() async -> TokenSourceResponse? { + /// - Returns: The cached response if found, nil otherwise. + public func cachedResponse() async -> TokenSourceResponse? { await store.retrieve()?.1 } } public extension TokenSourceConfigurable { - func cached(store: TokenStore = InMemoryTokenStore(), validator: @escaping CachingTokenSource.TokenValidator = { _, response in response.hasValidToken() }) -> CachingTokenSource { + /// 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: TokenStore = InMemoryTokenStore(), validator: @escaping CachingTokenSource.Validator = { _, response in response.hasValidToken() }) -> CachingTokenSource { CachingTokenSource(self, store: store, validator: validator) } } // MARK: - Store -/// Protocol for abstract store that can persist and retrieve a single cached credential pair. -/// Implement this protocol to create custom store implementations e.g. for Keychain. +/// 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 TokenStore: Sendable { - /// Store credentials in the store (replaces any existing credentials) + /// 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 + /// Retrieve the cached credentials. /// - Returns: The cached credentials if found, nil otherwise func retrieve() async -> CachingTokenSource.Cached? - /// Clear the stored credentials + /// Clear all stored credentials. func clear() async } -/// Simple in-memory store implementation +/// 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: TokenStore { private var cached: CachingTokenSource.Cached? @@ -117,7 +136,8 @@ public actor InMemoryTokenStore: TokenStore { // MARK: - Validation public extension TokenSourceResponse { - /// Validates whether the JWT token is still valid. + /// 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 { @@ -136,7 +156,8 @@ public extension TokenSourceResponse { } /// Extracts the JWT payload from the participant token. - /// - Returns: The JWT payload if found, nil otherwise + /// + /// - 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 index 7aaa141dc..a7876dce9 100644 --- a/Sources/LiveKit/Token/EndpointTokenSource.swift +++ b/Sources/LiveKit/Token/EndpointTokenSource.swift @@ -18,11 +18,18 @@ 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. -/// - Note: The response is expected to be a `Token.Response` object. +/// +/// 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 (defaults to "POST"). + /// 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 } diff --git a/Sources/LiveKit/Token/JWT.swift b/Sources/LiveKit/Token/JWT.swift index b0531594f..cc58ec158 100644 --- a/Sources/LiveKit/Token/JWT.swift +++ b/Sources/LiveKit/Token/JWT.swift @@ -16,32 +16,34 @@ 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, must be set for admin or join permissions + /// Name of the room. Required for admin or join permissions. public let room: String? - /// Permission to create a room + /// Permission to create new rooms. public let roomCreate: Bool? - /// Permission to join a room as a participant, room must be set + /// Permission to join a room as a participant. Requires `room` to be set. public let roomJoin: Bool? - /// Permission to list rooms + /// Permission to list available rooms. public let roomList: Bool? - /// Permission to start a recording + /// Permission to start recording sessions. public let roomRecord: Bool? - /// Permission to control a specific room, room must be set + /// Permission to control a specific room. Requires `room` to be set. public let roomAdmin: Bool? - /// Allow participant to publish. If neither canPublish or canSubscribe is set, both publish and subscribe are enabled + /// 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 tracks + /// Allow participant to subscribe to other participants' tracks. public let canSubscribe: Bool? - /// Allow participants to publish data, defaults to true if not set + /// Allow participant to publish data messages. Defaults to `true` if not set. public let canPublishData: Bool? - /// Allowed sources for publishing + /// Allowed track sources for publishing (e.g., "camera", "microphone", "screen_share"). public let canPublishSources: [String]? - /// Participant isn't visible to others + /// Hide participant from other participants in the room. public let hidden: Bool? - /// Participant is recording the room, when set, allows room to indicate it's being recorded + /// Mark participant as a recorder. When set, allows room to indicate it's being recorded. public let recorder: Bool? public init(room: String? = nil, @@ -72,27 +74,32 @@ public struct LiveKitJWTPayload: JWTPayload, Codable, Equatable { } } - /// Expiration time claim + /// JWT expiration time claim (when the token expires). public let exp: ExpirationClaim - /// Issuer claim + /// JWT issuer claim (who issued the token). public let iss: IssuerClaim - /// Not before claim + /// JWT not-before claim (when the token becomes valid). public let nbf: NotBeforeClaim - /// Subject claim + /// JWT subject claim (the participant identity). public let sub: SubjectClaim - /// Participant name + /// Display name for the participant in the room. public let name: String? - /// Participant metadata + /// Custom metadata associated with the participant. public let metadata: String? - /// Video grants for the participant + /// 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 index e8dacc724..4c83c23e1 100644 --- a/Sources/LiveKit/Token/LiteralTokenSource.swift +++ b/Sources/LiveKit/Token/LiteralTokenSource.swift @@ -16,12 +16,29 @@ 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 @@ -29,6 +46,9 @@ public struct LiteralTokenSource: TokenSourceFixed { 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 index b0d0d42c6..54b76c9ff 100644 --- a/Sources/LiveKit/Token/SandboxTokenSource.swift +++ b/Sources/LiveKit/Token/SandboxTokenSource.swift @@ -16,19 +16,25 @@ import Foundation -/// `SandboxTokenSource` queries LiveKit Sandbox [token server](https://cloud.livekit.io/projects/p_/sandbox/templates/token-server) for credentials, -/// which supports quick prototyping/getting started types of use cases. -/// - Warning: This token source is **INSECURE** and should **NOT** be used in production. +/// 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. + /// 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 index 708d0f832..e2688e525 100644 --- a/Sources/LiveKit/Token/TokenSource.swift +++ b/Sources/LiveKit/Token/TokenSource.swift @@ -18,10 +18,25 @@ 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 } @@ -30,18 +45,23 @@ public protocol TokenSourceConfigurable: Sendable { /// Request parameters for generating connection credentials. public struct TokenRequestOptions: Encodable, Sendable, Equatable { - /// The name of the room being requested when generating credentials. + /// The name of the room to connect to. Required for most token generation scenarios. public let roomName: String? - /// The name of the participant being requested for this client when generating credentials. + /// The display name for the participant in the room. Optional but recommended for user experience. public let participantName: String? - /// The identity of the participant being requested for this client when generating credentials. + /// A unique identifier for the participant. Used for permissions and room management. public let participantIdentity: String? - /// Any participant metadata being included along with the credentials generation operation. + /// Custom metadata associated with the participant. Can be used for user profiles or additional context. public let participantMetadata: String? - /// Any participant attributes being included along with the credentials generation operation. + /// Custom attributes for the participant. Useful for storing key-value data like user roles or preferences. public let participantAttributes: [String: String]? - /// A `RoomConfiguration` object can be passed to request extra parameters when generating connection credentials. - /// Used for advanced room configuration like dispatching agents, setting room limits, etc. + /// Advanced room configuration options for token generation. + /// + /// Use this for advanced features like: + /// - Dispatching agents to the room + /// - Setting room limits and constraints + /// - Configuring recording or streaming options + /// /// - SeeAlso: [Room Configuration Documentation](https://docs.livekit.io/home/get-started/authentication/#room-configuration) for more info. public let roomConfiguration: RoomConfiguration? @@ -71,15 +91,15 @@ public struct TokenRequestOptions: Encodable, Sendable, Equatable { } } -/// Response containing the credentials needed to connect to a room. +/// Response containing the credentials needed to connect to a LiveKit room. public struct TokenSourceResponse: Decodable, Sendable { - /// The WebSocket URL for the LiveKit server. + /// 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. + /// The JWT token containing participant permissions and metadata. Required for authentication. public let participantToken: String - /// The name of the participant being requested for this client when generating credentials. + /// The display name for the participant in the room. May be nil if not specified. public let participantName: String? - /// The name of the room being requested when generating credentials. + /// The name of the room the participant will join. May be nil if not specified. public let roomName: String? enum CodingKeys: String, CodingKey { From 6a4474a0856cf715976e49faa4768190da0dd860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Fri, 3 Oct 2025 14:21:51 +0200 Subject: [PATCH 30/32] Fix tests --- Tests/LiveKitTests/Auth/TokenSourceTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/LiveKitTests/Auth/TokenSourceTests.swift b/Tests/LiveKitTests/Auth/TokenSourceTests.swift index 855a4a3f3..6be57309b 100644 --- a/Tests/LiveKitTests/Auth/TokenSourceTests.swift +++ b/Tests/LiveKitTests/Auth/TokenSourceTests.swift @@ -161,7 +161,7 @@ class TokenSourceTests: LKTestCase { func testCustomValidator() async throws { let mockSource = MockValidJWTSource(participantName: "charlie") - let customValidator: CachingTokenSource.TokenValidator = { request, response in + let customValidator: CachingTokenSource.Validator = { request, response in request.participantName == "charlie" && response.hasValidToken() } @@ -198,7 +198,7 @@ class TokenSourceTests: LKTestCase { XCTAssertEqual(callCount4, 3) let tokenMockSource = MockValidJWTSource(participantName: "dave") - let tokenContentValidator: CachingTokenSource.TokenValidator = { request, response in + let tokenContentValidator: CachingTokenSource.Validator = { request, response in request.roomName == "test-room" && response.hasValidToken() } From 6feec3e35373016cb8850f4998c1c8c56aa74815 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Tue, 7 Oct 2025 10:04:58 +0200 Subject: [PATCH 31/32] Move tests --- .../LiveKit/Token/CachingTokenSource.swift | 47 ++++++++++--------- .../Token}/TokenSourceTests.swift | 3 +- 2 files changed, 26 insertions(+), 24 deletions(-) rename Tests/{LiveKitTests/Auth => LiveKitCoreTests/Token}/TokenSourceTests.swift (99%) diff --git a/Sources/LiveKit/Token/CachingTokenSource.swift b/Sources/LiveKit/Token/CachingTokenSource.swift index be4bf2259..861746978 100644 --- a/Sources/LiveKit/Token/CachingTokenSource.swift +++ b/Sources/LiveKit/Token/CachingTokenSource.swift @@ -23,14 +23,33 @@ import Foundation 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: TokenStore + private let store: Store private let validator: Validator /// Initialize a caching wrapper around any token source. @@ -41,7 +60,7 @@ public actor CachingTokenSource: TokenSourceConfigurable, Loggable { /// - validator: A closure to determine if cached credentials are still valid (defaults to JWT expiration check) public init( _ source: TokenSourceConfigurable, - store: TokenStore = InMemoryTokenStore(), + store: Store = InMemoryTokenStore(), validator: @escaping Validator = { _, response in response.hasValidToken() } ) { self.source = source @@ -86,36 +105,20 @@ public extension TokenSourceConfigurable { /// - 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: TokenStore = InMemoryTokenStore(), validator: @escaping CachingTokenSource.Validator = { _, response in response.hasValidToken() }) -> CachingTokenSource { + func cached(store: CachingTokenSource.Store = InMemoryTokenStore(), + validator: @escaping CachingTokenSource.Validator = { _, response in response.hasValidToken() }) -> CachingTokenSource + { CachingTokenSource(self, store: store, validator: validator) } } // MARK: - Store -/// 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 TokenStore: 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 -} - /// 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: TokenStore { +public actor InMemoryTokenStore: CachingTokenSource.Store { private var cached: CachingTokenSource.Cached? public init() {} diff --git a/Tests/LiveKitTests/Auth/TokenSourceTests.swift b/Tests/LiveKitCoreTests/Token/TokenSourceTests.swift similarity index 99% rename from Tests/LiveKitTests/Auth/TokenSourceTests.swift rename to Tests/LiveKitCoreTests/Token/TokenSourceTests.swift index 6be57309b..3fa6cff0f 100644 --- a/Tests/LiveKitTests/Auth/TokenSourceTests.swift +++ b/Tests/LiveKitCoreTests/Token/TokenSourceTests.swift @@ -14,9 +14,8 @@ * limitations under the License. */ -import Foundation @testable import LiveKit -import XCTest +import LiveKitTestSupport class TokenSourceTests: LKTestCase { actor MockValidJWTSource: TokenSourceConfigurable { From 69ee5ed067143d24695e2e3aeeec25342a3b59f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Tue, 14 Oct 2025 13:07:27 +0200 Subject: [PATCH 32/32] Extract options --- .../LiveKit/Token/EndpointTokenSource.swift | 3 +- Sources/LiveKit/Token/TokenSource.swift | 65 +++++++++++++------ 2 files changed, 46 insertions(+), 22 deletions(-) diff --git a/Sources/LiveKit/Token/EndpointTokenSource.swift b/Sources/LiveKit/Token/EndpointTokenSource.swift index a7876dce9..71dbff924 100644 --- a/Sources/LiveKit/Token/EndpointTokenSource.swift +++ b/Sources/LiveKit/Token/EndpointTokenSource.swift @@ -46,7 +46,8 @@ public extension EndpointTokenSource { for (key, value) in headers { urlRequest.addValue(value, forHTTPHeaderField: key) } - urlRequest.httpBody = try JSONEncoder().encode(options) + urlRequest.httpBody = try JSONEncoder().encode(options.toRequest()) + urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") let (data, response) = try await URLSession.shared.data(for: urlRequest) diff --git a/Sources/LiveKit/Token/TokenSource.swift b/Sources/LiveKit/Token/TokenSource.swift index e2688e525..4d611a371 100644 --- a/Sources/LiveKit/Token/TokenSource.swift +++ b/Sources/LiveKit/Token/TokenSource.swift @@ -44,7 +44,7 @@ public protocol TokenSourceConfigurable: Sendable { // MARK: - Token /// Request parameters for generating connection credentials. -public struct TokenRequestOptions: Encodable, Sendable, Equatable { +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. @@ -55,24 +55,10 @@ public struct TokenRequestOptions: Encodable, Sendable, Equatable { 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]? - /// Advanced room configuration options for token generation. - /// - /// Use this for advanced features like: - /// - Dispatching agents to the room - /// - Setting room limits and constraints - /// - Configuring recording or streaming options - /// - /// - SeeAlso: [Room Configuration Documentation](https://docs.livekit.io/home/get-started/authentication/#room-configuration) for more info. - public 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" - } + /// 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, @@ -80,14 +66,51 @@ public struct TokenRequestOptions: Encodable, Sendable, Equatable { participantIdentity: String? = nil, participantMetadata: String? = nil, participantAttributes: [String: String]? = nil, - roomConfiguration: RoomConfiguration? = nil + agentName: String? = nil, + agentMetadata: String? = nil ) { self.roomName = roomName self.participantName = participantName self.participantIdentity = participantIdentity self.participantMetadata = participantMetadata self.participantAttributes = participantAttributes - self.roomConfiguration = roomConfiguration + 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" } }