Skip to content

Commit 845c0a1

Browse files
authored
Token source (#901)
1 parent 5b0e9f1 commit 845c0a1

15 files changed

+2292
-19
lines changed

.changes/token-source

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
patch type="added" "Token source API with caching, endpoint helpers"

lib/livekit_client.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,11 @@ export 'src/types/video_encoding.dart';
6060
export 'src/types/video_parameters.dart';
6161
export 'src/widgets/screen_select_dialog.dart';
6262
export 'src/widgets/video_track_renderer.dart';
63+
export 'src/token_source/token_source.dart';
64+
export 'src/token_source/room_configuration.dart';
65+
export 'src/token_source/literal.dart';
66+
export 'src/token_source/endpoint.dart';
67+
export 'src/token_source/custom.dart';
68+
export 'src/token_source/caching.dart';
69+
export 'src/token_source/sandbox.dart';
70+
export 'src/token_source/jwt.dart';

lib/src/token_source/caching.dart

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// Copyright 2024 LiveKit, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import 'dart:async';
16+
17+
import '../support/reusable_completer.dart';
18+
import 'jwt.dart';
19+
import 'token_source.dart';
20+
21+
/// A validator function that determines if cached credentials are still valid.
22+
///
23+
/// The validator receives the original request options and cached response, and should
24+
/// return `true` if the cached credentials are still valid for the given request.
25+
///
26+
/// The default validator checks JWT expiration using [isResponseExpired].
27+
typedef TokenValidator = bool Function(TokenRequestOptions options, TokenSourceResponse response);
28+
29+
/// A tuple containing the request options and response that were cached.
30+
class TokenStoreItem {
31+
final TokenRequestOptions options;
32+
final TokenSourceResponse response;
33+
34+
const TokenStoreItem({
35+
required this.options,
36+
required this.response,
37+
});
38+
}
39+
40+
/// Protocol for storing and retrieving cached token credentials.
41+
///
42+
/// Implement this abstract class to create custom storage solutions like
43+
/// SharedPreferences or secure storage for token caching.
44+
abstract class TokenStore {
45+
/// Store credentials in the store.
46+
///
47+
/// This replaces any existing cached credentials with the new ones.
48+
Future<void> store(TokenRequestOptions options, TokenSourceResponse response);
49+
50+
/// Retrieve the cached credentials.
51+
///
52+
/// Returns the cached credentials if found, null otherwise.
53+
Future<TokenStoreItem?> retrieve();
54+
55+
/// Clear all stored credentials.
56+
Future<void> clear();
57+
}
58+
59+
/// A simple in-memory store implementation for token caching.
60+
///
61+
/// This store keeps credentials in memory and is lost when the app is terminated.
62+
/// Suitable for development and testing.
63+
class InMemoryTokenStore implements TokenStore {
64+
TokenStoreItem? _cached;
65+
66+
@override
67+
Future<void> store(TokenRequestOptions options, TokenSourceResponse response) async {
68+
_cached = TokenStoreItem(options: options, response: response);
69+
}
70+
71+
@override
72+
Future<TokenStoreItem?> retrieve() async {
73+
return _cached;
74+
}
75+
76+
@override
77+
Future<void> clear() async {
78+
_cached = null;
79+
}
80+
}
81+
82+
/// Default validator that checks JWT expiration using [isResponseExpired].
83+
bool _defaultValidator(TokenRequestOptions options, TokenSourceResponse response) {
84+
return !isResponseExpired(response);
85+
}
86+
87+
/// A token source that caches credentials from any [TokenSourceConfigurable] using a configurable store.
88+
///
89+
/// This wrapper improves performance by avoiding redundant token requests when credentials are still valid.
90+
/// It automatically validates cached tokens and fetches new ones when needed.
91+
///
92+
/// The cache will refetch credentials when:
93+
/// - The cached token has expired (validated via [TokenValidator])
94+
/// - The request options have changed
95+
/// - The cache has been explicitly invalidated via [invalidate]
96+
class CachingTokenSource implements TokenSourceConfigurable {
97+
final TokenSourceConfigurable _wrapped;
98+
final TokenStore _store;
99+
final TokenValidator _validator;
100+
final Map<TokenRequestOptions, ReusableCompleter<TokenSourceResponse>> _inflightRequests = {};
101+
102+
/// Initialize a caching wrapper around any token source.
103+
///
104+
/// - Parameters:
105+
/// - wrapped: The underlying token source to wrap and cache
106+
/// - store: The store implementation to use for caching (defaults to in-memory store)
107+
/// - validator: A function to determine if cached credentials are still valid (defaults to JWT expiration check)
108+
CachingTokenSource(
109+
this._wrapped, {
110+
TokenStore? store,
111+
TokenValidator? validator,
112+
}) : _store = store ?? InMemoryTokenStore(),
113+
_validator = validator ?? _defaultValidator;
114+
115+
@override
116+
Future<TokenSourceResponse> fetch(TokenRequestOptions options) async {
117+
final existingCompleter = _inflightRequests[options];
118+
if (existingCompleter != null && existingCompleter.isActive) {
119+
return existingCompleter.future;
120+
}
121+
122+
final completer = existingCompleter ?? ReusableCompleter<TokenSourceResponse>();
123+
_inflightRequests[options] = completer;
124+
final resultFuture = completer.future;
125+
126+
try {
127+
final cached = await _store.retrieve();
128+
if (cached != null && cached.options == options && _validator(cached.options, cached.response)) {
129+
completer.complete(cached.response);
130+
return resultFuture;
131+
}
132+
133+
final response = await _wrapped.fetch(options);
134+
await _store.store(options, response);
135+
completer.complete(response);
136+
return resultFuture;
137+
} catch (e, stackTrace) {
138+
completer.completeError(e, stackTrace);
139+
rethrow;
140+
} finally {
141+
_inflightRequests.remove(options);
142+
}
143+
}
144+
145+
/// Invalidate the cached credentials, forcing a fresh fetch on the next request.
146+
Future<void> invalidate() async {
147+
await _store.clear();
148+
}
149+
150+
/// Get the cached credentials if one exists.
151+
Future<TokenSourceResponse?> cachedResponse() async {
152+
final cached = await _store.retrieve();
153+
return cached?.response;
154+
}
155+
}
156+
157+
/// Extension to add caching capabilities to any [TokenSourceConfigurable].
158+
extension CachedTokenSource on TokenSourceConfigurable {
159+
/// Wraps this token source with caching capabilities.
160+
///
161+
/// The returned token source will reuse valid tokens and only fetch new ones when needed.
162+
///
163+
/// - Parameters:
164+
/// - store: The store implementation to use for caching (defaults to in-memory store)
165+
/// - validator: A function to determine if cached credentials are still valid (defaults to JWT expiration check)
166+
/// - Returns: A caching token source that wraps this token source
167+
CachingTokenSource cached({
168+
TokenStore? store,
169+
TokenValidator? validator,
170+
}) =>
171+
CachingTokenSource(
172+
this,
173+
store: store,
174+
validator: validator,
175+
);
176+
}

lib/src/token_source/custom.dart

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright 2024 LiveKit, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import 'token_source.dart';
16+
17+
/// Function signature for custom token generation logic.
18+
typedef CustomTokenFunction = Future<TokenSourceResponse> Function(TokenRequestOptions options);
19+
20+
/// A custom token source that executes provided logic to fetch credentials.
21+
///
22+
/// This allows you to implement your own token fetching strategy with full control
23+
/// over how credentials are generated or retrieved.
24+
class CustomTokenSource implements TokenSourceConfigurable {
25+
final CustomTokenFunction _function;
26+
27+
/// Initialize with a custom token generation function.
28+
///
29+
/// The [function] will be called whenever credentials need to be fetched,
30+
/// receiving [TokenRequestOptions] and returning a [TokenSourceResponse].
31+
CustomTokenSource(CustomTokenFunction function) : _function = function;
32+
33+
@override
34+
Future<TokenSourceResponse> fetch(TokenRequestOptions options) async => _function(options);
35+
}

lib/src/token_source/endpoint.dart

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright 2024 LiveKit, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import 'dart:convert';
16+
17+
import 'package:http/http.dart' as http;
18+
19+
import 'token_source.dart';
20+
21+
/// Error thrown when the token server responds with a non-success HTTP status code.
22+
class TokenSourceHttpException implements Exception {
23+
/// The endpoint that returned the error.
24+
final Uri uri;
25+
26+
/// The HTTP status code returned by the endpoint.
27+
final int statusCode;
28+
29+
/// The raw response body returned by the endpoint.
30+
final String body;
31+
32+
const TokenSourceHttpException({
33+
required this.uri,
34+
required this.statusCode,
35+
required this.body,
36+
});
37+
38+
@override
39+
String toString() {
40+
return 'TokenSourceHttpException(statusCode: $statusCode, uri: $uri, body: $body)';
41+
}
42+
}
43+
44+
/// A token source that fetches credentials via HTTP requests from a custom backend.
45+
///
46+
/// This implementation:
47+
/// - Sends a POST request to the specified URL (configurable via [method])
48+
/// - Encodes the request parameters as [TokenRequestOptions] JSON in the request body
49+
/// - Includes any custom headers specified via [headers]
50+
/// - Expects the response to be decoded as [TokenSourceResponse] JSON
51+
/// - Validates HTTP status codes (200-299) and throws appropriate errors for failures
52+
class EndpointTokenSource implements TokenSourceConfigurable {
53+
/// The URL endpoint for token generation.
54+
/// This should point to your backend service that generates LiveKit tokens.
55+
final Uri uri;
56+
57+
/// The HTTP method to use for the token request (defaults to "POST").
58+
final String method;
59+
60+
/// Additional HTTP headers to include with the request.
61+
final Map<String, String> headers;
62+
63+
/// Optional HTTP client for testing purposes.
64+
final http.Client? client;
65+
66+
/// Initialize with endpoint configuration.
67+
///
68+
/// - [url]: The URL endpoint for token generation
69+
/// - [method]: The HTTP method (defaults to "POST")
70+
/// - [headers]: Additional HTTP headers (optional)
71+
/// - [client]: Custom HTTP client for testing (optional)
72+
EndpointTokenSource({
73+
required Uri url,
74+
this.method = 'POST',
75+
this.headers = const {},
76+
this.client,
77+
}) : uri = url;
78+
79+
@override
80+
Future<TokenSourceResponse> fetch(TokenRequestOptions options) async {
81+
final requestBody = jsonEncode(options.toRequest().toJson());
82+
final requestHeaders = {
83+
'Content-Type': 'application/json',
84+
...headers,
85+
};
86+
87+
final httpClient = client ?? http.Client();
88+
final shouldCloseClient = client == null;
89+
late final http.Response response;
90+
91+
try {
92+
final request = http.Request(method, uri);
93+
request.headers.addAll(requestHeaders);
94+
request.body = requestBody;
95+
final streamedResponse = await httpClient.send(request);
96+
response = await http.Response.fromStream(streamedResponse);
97+
} finally {
98+
if (shouldCloseClient) {
99+
httpClient.close();
100+
}
101+
}
102+
103+
if (response.statusCode < 200 || response.statusCode >= 300) {
104+
throw TokenSourceHttpException(
105+
uri: uri,
106+
statusCode: response.statusCode,
107+
body: response.body,
108+
);
109+
}
110+
111+
final responseBody = jsonDecode(response.body) as Map<String, dynamic>;
112+
return TokenSourceResponse.fromJson(responseBody);
113+
}
114+
}

0 commit comments

Comments
 (0)