Skip to content

Commit 2bd7b7a

Browse files
authored
Token source (#787)
Incarnation of: - livekit/client-sdk-js#1645 🌏 - livekit/client-sdk-android#769 🤖 Key differences: - Mostly leverages Swift-native approach (protocols + default impl) - Caching is opt-in wrapper (which could be extended to Keychain tho)
1 parent 7981852 commit 2bd7b7a

File tree

13 files changed

+964
-96
lines changed

13 files changed

+964
-96
lines changed

.changes/connection-credentials

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
patch type="added" "Abstract token source for easier token fetching in production and faster integration with sandbox environment"

Package.swift

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,9 @@ let package = Package(
2222
.package(url: "https://github.com/livekit/webrtc-xcframework.git", exact: "137.7151.09"),
2323
.package(url: "https://github.com/apple/swift-protobuf.git", from: "1.31.0"),
2424
.package(url: "https://github.com/apple/swift-collections.git", "1.1.0" ..< "1.3.0"),
25+
.package(url: "https://github.com/vapor/jwt-kit.git", from: "4.13.5"),
2526
// Only used for DocC generation
2627
.package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.3.0"),
27-
// Only used for Testing
28-
.package(url: "https://github.com/vapor/jwt-kit.git", from: "4.13.4"),
2928
],
3029
targets: [
3130
.target(
@@ -39,6 +38,7 @@ let package = Package(
3938
.product(name: "SwiftProtobuf", package: "swift-protobuf"),
4039
.product(name: "DequeModule", package: "swift-collections"),
4140
.product(name: "OrderedCollections", package: "swift-collections"),
41+
.product(name: "JWTKit", package: "jwt-kit"),
4242
"LKObjCHelpers",
4343
],
4444
exclude: [
@@ -55,7 +55,6 @@ let package = Package(
5555
name: "LiveKitTestSupport",
5656
dependencies: [
5757
"LiveKit",
58-
.product(name: "JWTKit", package: "jwt-kit"),
5958
],
6059
path: "Tests/LiveKitTestSupport"
6160
),

Package@swift-6.0.swift

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,9 @@ let package = Package(
2323
.package(url: "https://github.com/livekit/webrtc-xcframework.git", exact: "137.7151.09"),
2424
.package(url: "https://github.com/apple/swift-protobuf.git", from: "1.31.0"),
2525
.package(url: "https://github.com/apple/swift-collections.git", "1.1.0" ..< "1.3.0"),
26+
.package(url: "https://github.com/vapor/jwt-kit.git", from: "4.13.5"),
2627
// Only used for DocC generation
2728
.package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.3.0"),
28-
// Only used for Testing
29-
.package(url: "https://github.com/vapor/jwt-kit.git", from: "4.13.4"),
3029
],
3130
targets: [
3231
.target(
@@ -40,6 +39,7 @@ let package = Package(
4039
.product(name: "SwiftProtobuf", package: "swift-protobuf"),
4140
.product(name: "DequeModule", package: "swift-collections"),
4241
.product(name: "OrderedCollections", package: "swift-collections"),
42+
.product(name: "JWTKit", package: "jwt-kit"),
4343
"LKObjCHelpers",
4444
],
4545
exclude: [
@@ -56,7 +56,6 @@ let package = Package(
5656
name: "LiveKitTestSupport",
5757
dependencies: [
5858
"LiveKit",
59-
.product(name: "JWTKit", package: "jwt-kit"),
6059
],
6160
path: "Tests/LiveKitTestSupport"
6261
),
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/*
2+
* Copyright 2025 LiveKit
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import Foundation
18+
19+
/// A token source that caches credentials from any other ``TokenSourceConfigurable`` using a configurable store.
20+
///
21+
/// This wrapper improves performance by avoiding redundant token requests when credentials are still valid.
22+
/// It automatically validates cached tokens and fetches new ones when needed.
23+
public actor CachingTokenSource: TokenSourceConfigurable, Loggable {
24+
/// A tuple containing the request and response that were cached.
25+
public typealias Cached = (TokenRequestOptions, TokenSourceResponse)
26+
27+
/// A closure that validates whether cached credentials are still valid.
28+
///
29+
/// The validator receives the original request options and cached response, and should return
30+
/// `true` if the cached credentials are still valid for the given request.
31+
public typealias Validator = @Sendable (TokenRequestOptions, TokenSourceResponse) -> Bool
32+
33+
/// Protocol for storing and retrieving cached token credentials.
34+
///
35+
/// Implement this protocol to create custom storage solutions like Keychain,
36+
/// or database-backed storage for token caching.
37+
public protocol Store: Sendable {
38+
/// Store credentials in the store.
39+
///
40+
/// This replaces any existing cached credentials with the new ones.
41+
func store(_ credentials: CachingTokenSource.Cached) async
42+
43+
/// Retrieve the cached credentials.
44+
/// - Returns: The cached credentials if found, nil otherwise
45+
func retrieve() async -> CachingTokenSource.Cached?
46+
47+
/// Clear all stored credentials.
48+
func clear() async
49+
}
50+
51+
private let source: TokenSourceConfigurable
52+
private let store: Store
53+
private let validator: Validator
54+
55+
/// Initialize a caching wrapper around any token source.
56+
///
57+
/// - Parameters:
58+
/// - source: The underlying token source to wrap and cache
59+
/// - store: The store implementation to use for caching (defaults to in-memory store)
60+
/// - validator: A closure to determine if cached credentials are still valid (defaults to JWT expiration check)
61+
public init(
62+
_ source: TokenSourceConfigurable,
63+
store: Store = InMemoryTokenStore(),
64+
validator: @escaping Validator = { _, response in response.hasValidToken() }
65+
) {
66+
self.source = source
67+
self.store = store
68+
self.validator = validator
69+
}
70+
71+
public func fetch(_ options: TokenRequestOptions) async throws -> TokenSourceResponse {
72+
if let (cachedOptions, cachedResponse) = await store.retrieve(),
73+
cachedOptions == options,
74+
validator(cachedOptions, cachedResponse)
75+
{
76+
log("Using cached credentials", .debug)
77+
return cachedResponse
78+
}
79+
80+
log("Requesting new credentials", .debug)
81+
let newResponse = try await source.fetch(options)
82+
await store.store((options, newResponse))
83+
84+
return newResponse
85+
}
86+
87+
/// Invalidate the cached credentials, forcing a fresh fetch on the next request.
88+
public func invalidate() async {
89+
await store.clear()
90+
}
91+
92+
/// Get the cached credentials
93+
/// - Returns: The cached response if found, nil otherwise.
94+
public func cachedResponse() async -> TokenSourceResponse? {
95+
await store.retrieve()?.1
96+
}
97+
}
98+
99+
public extension TokenSourceConfigurable {
100+
/// Wraps this token source with caching capabilities.
101+
///
102+
/// The returned token source will reuse valid tokens and only fetch new ones when needed.
103+
///
104+
/// - Parameters:
105+
/// - store: The store implementation to use for caching (defaults to in-memory store)
106+
/// - validator: A closure to determine if cached credentials are still valid (defaults to JWT expiration check)
107+
/// - Returns: A caching token source that wraps this token source
108+
func cached(store: CachingTokenSource.Store = InMemoryTokenStore(),
109+
validator: @escaping CachingTokenSource.Validator = { _, response in response.hasValidToken() }) -> CachingTokenSource
110+
{
111+
CachingTokenSource(self, store: store, validator: validator)
112+
}
113+
}
114+
115+
// MARK: - Store
116+
117+
/// A simple in-memory store implementation for token caching.
118+
///
119+
/// This store keeps credentials in memory and is lost when the app is terminated.
120+
/// Suitable for development and testing, but consider persistent storage for production.
121+
public actor InMemoryTokenStore: CachingTokenSource.Store {
122+
private var cached: CachingTokenSource.Cached?
123+
124+
public init() {}
125+
126+
public func store(_ credentials: CachingTokenSource.Cached) async {
127+
cached = credentials
128+
}
129+
130+
public func retrieve() async -> CachingTokenSource.Cached? {
131+
cached
132+
}
133+
134+
public func clear() async {
135+
cached = nil
136+
}
137+
}
138+
139+
// MARK: - Validation
140+
141+
public extension TokenSourceResponse {
142+
/// Validates whether the JWT token is still valid and not expired.
143+
///
144+
/// - Parameter tolerance: Time tolerance in seconds for token expiration check (default: 60 seconds)
145+
/// - Returns: `true` if the token is valid and not expired, `false` otherwise
146+
func hasValidToken(withTolerance tolerance: TimeInterval = 60) -> Bool {
147+
guard let jwt = jwt() else {
148+
return false
149+
}
150+
151+
do {
152+
try jwt.nbf.verifyNotBefore()
153+
try jwt.exp.verifyNotExpired(currentDate: Date().addingTimeInterval(tolerance))
154+
} catch {
155+
return false
156+
}
157+
158+
return true
159+
}
160+
161+
/// Extracts the JWT payload from the participant token.
162+
///
163+
/// - Returns: The JWT payload if successfully parsed, nil otherwise
164+
func jwt() -> LiveKitJWTPayload? {
165+
LiveKitJWTPayload.fromUnverified(token: participantToken)
166+
}
167+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
* Copyright 2025 LiveKit
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import Foundation
18+
19+
/// Protocol for token servers that fetch credentials via HTTP requests.
20+
/// Provides a default implementation of `fetch` that can be used to integrate with custom backend token generation endpoints.
21+
///
22+
/// The default implementation:
23+
/// - Sends a POST request to the specified URL
24+
/// - Encodes the request parameters as ``TokenRequestOptions`` JSON in the request body
25+
/// - Includes any custom headers specified by the implementation
26+
/// - Expects the response to be decoded as ``TokenSourceResponse`` JSON
27+
/// - Validates HTTP status codes (200-299) and throws appropriate errors for failures
28+
public protocol EndpointTokenSource: TokenSourceConfigurable {
29+
/// The URL endpoint for token generation.
30+
/// This should point to your backend service that generates LiveKit tokens.
31+
var url: URL { get }
32+
/// The HTTP method to use for the token request (defaults to "POST").
33+
var method: String { get }
34+
/// Additional HTTP headers to include with the request.
35+
var headers: [String: String] { get }
36+
}
37+
38+
public extension EndpointTokenSource {
39+
var method: String { "POST" }
40+
var headers: [String: String] { [:] }
41+
42+
func fetch(_ options: TokenRequestOptions) async throws -> TokenSourceResponse {
43+
var urlRequest = URLRequest(url: url)
44+
45+
urlRequest.httpMethod = method
46+
for (key, value) in headers {
47+
urlRequest.addValue(value, forHTTPHeaderField: key)
48+
}
49+
urlRequest.httpBody = try JSONEncoder().encode(options.toRequest())
50+
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
51+
52+
let (data, response) = try await URLSession.shared.data(for: urlRequest)
53+
54+
guard let httpResponse = response as? HTTPURLResponse else {
55+
throw LiveKitError(.network, message: "Error generating token from the token server, no response")
56+
}
57+
58+
guard (200 ..< 300).contains(httpResponse.statusCode) else {
59+
throw LiveKitError(.network, message: "Error generating token from the token server, received \(httpResponse)")
60+
}
61+
62+
return try JSONDecoder().decode(TokenSourceResponse.self, from: data)
63+
}
64+
}

Sources/LiveKit/Token/JWT.swift

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*
2+
* Copyright 2025 LiveKit
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import JWTKit
18+
19+
/// JWT payload structure for LiveKit authentication tokens.
20+
public struct LiveKitJWTPayload: JWTPayload, Codable, Equatable {
21+
/// Video-specific permissions and room access grants for the participant.
22+
public struct VideoGrant: Codable, Equatable {
23+
/// Name of the room. Required for admin or join permissions.
24+
public let room: String?
25+
/// Permission to create new rooms.
26+
public let roomCreate: Bool?
27+
/// Permission to join a room as a participant. Requires `room` to be set.
28+
public let roomJoin: Bool?
29+
/// Permission to list available rooms.
30+
public let roomList: Bool?
31+
/// Permission to start recording sessions.
32+
public let roomRecord: Bool?
33+
/// Permission to control a specific room. Requires `room` to be set.
34+
public let roomAdmin: Bool?
35+
36+
/// Allow participant to publish tracks. If neither `canPublish` or `canSubscribe` is set, both are enabled.
37+
public let canPublish: Bool?
38+
/// Allow participant to subscribe to other participants' tracks.
39+
public let canSubscribe: Bool?
40+
/// Allow participant to publish data messages. Defaults to `true` if not set.
41+
public let canPublishData: Bool?
42+
/// Allowed track sources for publishing (e.g., "camera", "microphone", "screen_share").
43+
public let canPublishSources: [String]?
44+
/// Hide participant from other participants in the room.
45+
public let hidden: Bool?
46+
/// Mark participant as a recorder. When set, allows room to indicate it's being recorded.
47+
public let recorder: Bool?
48+
49+
public init(room: String? = nil,
50+
roomCreate: Bool? = nil,
51+
roomJoin: Bool? = nil,
52+
roomList: Bool? = nil,
53+
roomRecord: Bool? = nil,
54+
roomAdmin: Bool? = nil,
55+
canPublish: Bool? = nil,
56+
canSubscribe: Bool? = nil,
57+
canPublishData: Bool? = nil,
58+
canPublishSources: [String]? = nil,
59+
hidden: Bool? = nil,
60+
recorder: Bool? = nil)
61+
{
62+
self.room = room
63+
self.roomCreate = roomCreate
64+
self.roomJoin = roomJoin
65+
self.roomList = roomList
66+
self.roomRecord = roomRecord
67+
self.roomAdmin = roomAdmin
68+
self.canPublish = canPublish
69+
self.canSubscribe = canSubscribe
70+
self.canPublishData = canPublishData
71+
self.canPublishSources = canPublishSources
72+
self.hidden = hidden
73+
self.recorder = recorder
74+
}
75+
}
76+
77+
/// JWT expiration time claim (when the token expires).
78+
public let exp: ExpirationClaim
79+
/// JWT issuer claim (who issued the token).
80+
public let iss: IssuerClaim
81+
/// JWT not-before claim (when the token becomes valid).
82+
public let nbf: NotBeforeClaim
83+
/// JWT subject claim (the participant identity).
84+
public let sub: SubjectClaim
85+
86+
/// Display name for the participant in the room.
87+
public let name: String?
88+
/// Custom metadata associated with the participant.
89+
public let metadata: String?
90+
/// Video-specific permissions and room access grants.
91+
public let video: VideoGrant?
92+
93+
/// Verifies the JWT token's validity by checking expiration and not-before claims.
94+
public func verify(using _: JWTSigner) throws {
95+
try nbf.verifyNotBefore()
96+
try exp.verifyNotExpired()
97+
}
98+
99+
/// Creates a JWT payload from an unverified token string.
100+
///
101+
/// - Parameter token: The JWT token string to parse
102+
/// - Returns: The parsed JWT payload if successful, nil otherwise
103+
static func fromUnverified(token: String) -> Self? {
104+
try? JWTSigners().unverified(token, as: Self.self)
105+
}
106+
}

0 commit comments

Comments
 (0)