Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
26a4490
Basic providers with cache
pblazej Sep 16, 2025
b0cf3a7
Split token server
pblazej Sep 16, 2025
d88c2fb
Pass options
pblazej Sep 16, 2025
5aacd73
Expose RoomConfiguration (without pb)
pblazej Sep 16, 2025
b7e999e
Add some tests
pblazej Sep 16, 2025
e311976
Cmts
pblazej Sep 16, 2025
894d24e
Extract storage
pblazej Sep 16, 2025
d2c10ea
Change
pblazej Sep 16, 2025
f20c249
Expose cached, naming
pblazej Sep 17, 2025
44a4b8e
JSON keys
pblazej Sep 17, 2025
69d2ce0
Cache provider
pblazej Sep 17, 2025
1d12469
Log
pblazej Sep 17, 2025
8109684
Renaming
pblazej Sep 19, 2025
6b2eb9c
Fix tests
pblazej Sep 19, 2025
96c4ba5
Move Sandbox
pblazej Sep 19, 2025
43df84e
Public, comments
pblazej Sep 19, 2025
18ea71b
Nitpicks
pblazej Sep 19, 2025
cdd5f1c
Change
pblazej Sep 19, 2025
3d45795
JWT
pblazej Sep 19, 2025
aa2f08c
Filter
pblazej Sep 19, 2025
8673296
Keys
pblazej Sep 24, 2025
748ddd6
Mutable variant with setRequest
pblazej Sep 24, 2025
0c89008
Revert "Mutable variant with setRequest"
pblazej Sep 25, 2025
22f8d77
Remove Room integration
pblazej Oct 2, 2025
eea7c26
WIP
pblazej Oct 2, 2025
b57e454
Move, fix serialization
pblazej Oct 3, 2025
71fe3dd
Fix tests
pblazej Oct 3, 2025
4bbdca8
Move
pblazej Oct 3, 2025
6ce5ad8
Comments
pblazej Oct 3, 2025
6a4474a
Fix tests
pblazej Oct 3, 2025
86a445b
Merge branch 'main' into blaze/connection-provider
pblazej Oct 7, 2025
6feec3e
Move tests
pblazej Oct 7, 2025
69ee5ed
Extract options
pblazej Oct 14, 2025
b71debc
Merge branch 'main' into blaze/connection-provider
pblazej Oct 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changes/connection-credentials
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
patch type="added" "Abstract token source for easier token fetching in production and faster integration with sandbox environment"
5 changes: 2 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,9 @@ let package = Package(
.package(url: "https://github.com/livekit/webrtc-xcframework.git", exact: "137.7151.09"),
.package(url: "https://github.com/apple/swift-protobuf.git", from: "1.31.0"),
.package(url: "https://github.com/apple/swift-collections.git", "1.1.0" ..< "1.3.0"),
.package(url: "https://github.com/vapor/jwt-kit.git", from: "4.13.5"),
// Only used for DocC generation
.package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.3.0"),
// Only used for Testing
.package(url: "https://github.com/vapor/jwt-kit.git", from: "4.13.4"),
],
targets: [
.target(
Expand All @@ -39,6 +38,7 @@ let package = Package(
.product(name: "SwiftProtobuf", package: "swift-protobuf"),
.product(name: "DequeModule", package: "swift-collections"),
.product(name: "OrderedCollections", package: "swift-collections"),
.product(name: "JWTKit", package: "jwt-kit"),
"LKObjCHelpers",
],
exclude: [
Expand All @@ -55,7 +55,6 @@ let package = Package(
name: "LiveKitTestSupport",
dependencies: [
"LiveKit",
.product(name: "JWTKit", package: "jwt-kit"),
],
path: "Tests/LiveKitTestSupport"
),
Expand Down
5 changes: 2 additions & 3 deletions [email protected]
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,9 @@ let package = Package(
.package(url: "https://github.com/livekit/webrtc-xcframework.git", exact: "137.7151.09"),
.package(url: "https://github.com/apple/swift-protobuf.git", from: "1.31.0"),
.package(url: "https://github.com/apple/swift-collections.git", "1.1.0" ..< "1.3.0"),
.package(url: "https://github.com/vapor/jwt-kit.git", from: "4.13.5"),
// Only used for DocC generation
.package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.3.0"),
// Only used for Testing
.package(url: "https://github.com/vapor/jwt-kit.git", from: "4.13.4"),
],
targets: [
.target(
Expand All @@ -40,6 +39,7 @@ let package = Package(
.product(name: "SwiftProtobuf", package: "swift-protobuf"),
.product(name: "DequeModule", package: "swift-collections"),
.product(name: "OrderedCollections", package: "swift-collections"),
.product(name: "JWTKit", package: "jwt-kit"),
"LKObjCHelpers",
],
exclude: [
Expand All @@ -56,7 +56,6 @@ let package = Package(
name: "LiveKitTestSupport",
dependencies: [
"LiveKit",
.product(name: "JWTKit", package: "jwt-kit"),
],
path: "Tests/LiveKitTestSupport"
),
Expand Down
167 changes: 167 additions & 0 deletions Sources/LiveKit/Token/CachingTokenSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/*
* Copyright 2025 LiveKit
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import Foundation

/// A token source that caches credentials from any other ``TokenSourceConfigurable`` using a configurable store.
///
/// This wrapper improves performance by avoiding redundant token requests when credentials are still valid.
/// It automatically validates cached tokens and fetches new ones when needed.
public actor CachingTokenSource: TokenSourceConfigurable, Loggable {
/// A tuple containing the request and response that were cached.
public typealias Cached = (TokenRequestOptions, TokenSourceResponse)

/// A closure that validates whether cached credentials are still valid.
///
/// The validator receives the original request options and cached response, and should return
/// `true` if the cached credentials are still valid for the given request.
public typealias Validator = @Sendable (TokenRequestOptions, TokenSourceResponse) -> Bool

/// Protocol for storing and retrieving cached token credentials.
///
/// Implement this protocol to create custom storage solutions like Keychain,
/// or database-backed storage for token caching.
public protocol Store: Sendable {
/// Store credentials in the store.
///
/// This replaces any existing cached credentials with the new ones.
func store(_ credentials: CachingTokenSource.Cached) async

/// Retrieve the cached credentials.
/// - Returns: The cached credentials if found, nil otherwise
func retrieve() async -> CachingTokenSource.Cached?

/// Clear all stored credentials.
func clear() async
}

private let source: TokenSourceConfigurable
private let store: Store
private let validator: Validator

/// Initialize a caching wrapper around any token source.
///
/// - Parameters:
/// - source: The underlying token source to wrap and cache
/// - store: The store implementation to use for caching (defaults to in-memory store)
/// - validator: A closure to determine if cached credentials are still valid (defaults to JWT expiration check)
public init(
_ source: TokenSourceConfigurable,
store: Store = InMemoryTokenStore(),
validator: @escaping Validator = { _, response in response.hasValidToken() }
) {
self.source = source
self.store = store
self.validator = validator
}

public func fetch(_ options: TokenRequestOptions) async throws -> TokenSourceResponse {
if let (cachedOptions, cachedResponse) = await store.retrieve(),
cachedOptions == options,
validator(cachedOptions, cachedResponse)
{
log("Using cached credentials", .debug)
return cachedResponse
}

log("Requesting new credentials", .debug)
let newResponse = try await source.fetch(options)
await store.store((options, newResponse))

return newResponse
}

/// Invalidate the cached credentials, forcing a fresh fetch on the next request.
public func invalidate() async {
await store.clear()
}

/// Get the cached credentials
/// - Returns: The cached response if found, nil otherwise.
public func cachedResponse() async -> TokenSourceResponse? {
await store.retrieve()?.1
}
}

public extension TokenSourceConfigurable {
/// Wraps this token source with caching capabilities.
///
/// The returned token source will reuse valid tokens and only fetch new ones when needed.
///
/// - Parameters:
/// - store: The store implementation to use for caching (defaults to in-memory store)
/// - validator: A closure to determine if cached credentials are still valid (defaults to JWT expiration check)
/// - Returns: A caching token source that wraps this token source
func cached(store: CachingTokenSource.Store = InMemoryTokenStore(),
validator: @escaping CachingTokenSource.Validator = { _, response in response.hasValidToken() }) -> CachingTokenSource
{
CachingTokenSource(self, store: store, validator: validator)
}
}

// MARK: - Store

/// A simple in-memory store implementation for token caching.
///
/// This store keeps credentials in memory and is lost when the app is terminated.
/// Suitable for development and testing, but consider persistent storage for production.
public actor InMemoryTokenStore: CachingTokenSource.Store {
private var cached: CachingTokenSource.Cached?

public init() {}

public func store(_ credentials: CachingTokenSource.Cached) async {
cached = credentials
}

public func retrieve() async -> CachingTokenSource.Cached? {
cached
}

public func clear() async {
cached = nil
}
}

// MARK: - Validation

public extension TokenSourceResponse {
/// Validates whether the JWT token is still valid and not expired.
///
/// - Parameter tolerance: Time tolerance in seconds for token expiration check (default: 60 seconds)
/// - Returns: `true` if the token is valid and not expired, `false` otherwise
func hasValidToken(withTolerance tolerance: TimeInterval = 60) -> Bool {
guard let jwt = jwt() else {
return false
}

do {
try jwt.nbf.verifyNotBefore()
try jwt.exp.verifyNotExpired(currentDate: Date().addingTimeInterval(tolerance))
} catch {
return false
}

return true
}

/// Extracts the JWT payload from the participant token.
///
/// - Returns: The JWT payload if successfully parsed, nil otherwise
func jwt() -> LiveKitJWTPayload? {
LiveKitJWTPayload.fromUnverified(token: participantToken)
}
}
64 changes: 64 additions & 0 deletions Sources/LiveKit/Token/EndpointTokenSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright 2025 LiveKit
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import Foundation

/// Protocol for token servers that fetch credentials via HTTP requests.
/// Provides a default implementation of `fetch` that can be used to integrate with custom backend token generation endpoints.
///
/// The default implementation:
/// - Sends a POST request to the specified URL
/// - Encodes the request parameters as ``TokenRequestOptions`` JSON in the request body
/// - Includes any custom headers specified by the implementation
/// - Expects the response to be decoded as ``TokenSourceResponse`` JSON
/// - Validates HTTP status codes (200-299) and throws appropriate errors for failures
public protocol EndpointTokenSource: TokenSourceConfigurable {
/// The URL endpoint for token generation.
/// This should point to your backend service that generates LiveKit tokens.
var url: URL { get }
/// The HTTP method to use for the token request (defaults to "POST").
var method: String { get }
/// Additional HTTP headers to include with the request.
var headers: [String: String] { get }
}

public extension EndpointTokenSource {
var method: String { "POST" }
var headers: [String: String] { [:] }

func fetch(_ options: TokenRequestOptions) async throws -> TokenSourceResponse {
var urlRequest = URLRequest(url: url)

urlRequest.httpMethod = method
for (key, value) in headers {
urlRequest.addValue(value, forHTTPHeaderField: key)
}
urlRequest.httpBody = try JSONEncoder().encode(options.toRequest())
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")

let (data, response) = try await URLSession.shared.data(for: urlRequest)

guard let httpResponse = response as? HTTPURLResponse else {
throw LiveKitError(.network, message: "Error generating token from the token server, no response")
}

guard (200 ..< 300).contains(httpResponse.statusCode) else {
throw LiveKitError(.network, message: "Error generating token from the token server, received \(httpResponse)")
}

return try JSONDecoder().decode(TokenSourceResponse.self, from: data)
}
}
106 changes: 106 additions & 0 deletions Sources/LiveKit/Token/JWT.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Copyright 2025 LiveKit
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import JWTKit

/// JWT payload structure for LiveKit authentication tokens.
public struct LiveKitJWTPayload: JWTPayload, Codable, Equatable {
/// Video-specific permissions and room access grants for the participant.
public struct VideoGrant: Codable, Equatable {
/// Name of the room. Required for admin or join permissions.
public let room: String?
/// Permission to create new rooms.
public let roomCreate: Bool?
/// Permission to join a room as a participant. Requires `room` to be set.
public let roomJoin: Bool?
/// Permission to list available rooms.
public let roomList: Bool?
/// Permission to start recording sessions.
public let roomRecord: Bool?
/// Permission to control a specific room. Requires `room` to be set.
public let roomAdmin: Bool?

/// Allow participant to publish tracks. If neither `canPublish` or `canSubscribe` is set, both are enabled.
public let canPublish: Bool?
/// Allow participant to subscribe to other participants' tracks.
public let canSubscribe: Bool?
/// Allow participant to publish data messages. Defaults to `true` if not set.
public let canPublishData: Bool?
/// Allowed track sources for publishing (e.g., "camera", "microphone", "screen_share").
public let canPublishSources: [String]?
/// Hide participant from other participants in the room.
public let hidden: Bool?
/// Mark participant as a recorder. When set, allows room to indicate it's being recorded.
public let recorder: Bool?

public init(room: String? = nil,
roomCreate: Bool? = nil,
roomJoin: Bool? = nil,
roomList: Bool? = nil,
roomRecord: Bool? = nil,
roomAdmin: Bool? = nil,
canPublish: Bool? = nil,
canSubscribe: Bool? = nil,
canPublishData: Bool? = nil,
canPublishSources: [String]? = nil,
hidden: Bool? = nil,
recorder: Bool? = nil)
{
self.room = room
self.roomCreate = roomCreate
self.roomJoin = roomJoin
self.roomList = roomList
self.roomRecord = roomRecord
self.roomAdmin = roomAdmin
self.canPublish = canPublish
self.canSubscribe = canSubscribe
self.canPublishData = canPublishData
self.canPublishSources = canPublishSources
self.hidden = hidden
self.recorder = recorder
}
}

/// JWT expiration time claim (when the token expires).
public let exp: ExpirationClaim
/// JWT issuer claim (who issued the token).
public let iss: IssuerClaim
/// JWT not-before claim (when the token becomes valid).
public let nbf: NotBeforeClaim
/// JWT subject claim (the participant identity).
public let sub: SubjectClaim

/// Display name for the participant in the room.
public let name: String?
/// Custom metadata associated with the participant.
public let metadata: String?
/// Video-specific permissions and room access grants.
public let video: VideoGrant?

/// Verifies the JWT token's validity by checking expiration and not-before claims.
public func verify(using _: JWTSigner) throws {
try nbf.verifyNotBefore()
try exp.verifyNotExpired()
}

/// Creates a JWT payload from an unverified token string.
///
/// - Parameter token: The JWT token string to parse
/// - Returns: The parsed JWT payload if successful, nil otherwise
static func fromUnverified(token: String) -> Self? {
try? JWTSigners().unverified(token, as: Self.self)
}
}
Loading
Loading