Skip to content

Commit c677c3d

Browse files
authored
feat: Centralize platform credential handling in CredentialConfig (#300)
## What this changes Platform credential variance moves into the per-platform `CredentialConfig`, the same way the per-platform path templates already absorb endpoint differences. The consumers lose their inline credential-type switches: - **`baseHeaders(credential, userAgent)`** — io sends the user agent under `user-agent` and carries the credential in the `authorization` header; web sends the user agent under `x-launchdarkly-user-agent` and carries no authorization header. A browser only delivers the authorization header when the target service's CORS pre-flight response lists it as allowed, so a missed allow-list entry on any endpoint silently breaks authentication; web therefore authenticates with the `auth` query parameter, which is not subject to that allow-list (FDv1 client-side authentication is already URL-based). `_makeHttpProperties` becomes a single `addAll`. - **`environmentIdFallback(credential)`** — a client-side ID identifies the environment directly, so it serves as the environment ID when a connection exposes no response headers; a mobile key does not. Both the FDv1 streaming data source and the polling requestor consume this instead of checking the credential type inline. - **`authQueryParameters(credential)`** — authentication for every data acquisition request in the browser: `auth=<credential>` on web, empty on io (every io transport authenticates via the base headers). No consumer yet; the FDv2 sources (a later PR) use it for both streaming and polling, since the FDv2 endpoint paths do not embed the credential the way the FDv1 client-side paths do. The unconsumed `common_client` copy of `NetworkConfig` is removed: the restricted-header filter that actually executes reads the `launchdarkly_dart_common` configuration, and keeping a dead duplicate invites divergence. Behavior is unchanged on every platform: io requests carry the same headers as before, web requests carry the same headers as before, and the environment ID fallback produces the same values. ## Testing New tests cover the `CredentialConfig` methods for both platform variants directly (header construction, auth query parameters, environment ID fallback), and the streaming data source's environment ID selection: taken from the `x-ld-envid` response header when present, and from the platform fallback when the connection exposes no headers. The streaming test harness now forwards the environment ID to the event handler the way the production pipeline does. The remaining paths are covered by the existing suite, which passes unchanged. SDK-2188 <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches authentication headers and environment ID resolution on every HTTP/SSE path; behavior is intended to be unchanged but mistakes would break SDK connectivity. > > **Overview** > Platform-specific credential behavior moves into per-platform **`CredentialConfig`** (io, js, stub), replacing scattered `CredentialType` switches in the client and data sources. > > **`baseHeaders`**, **`authQueryParameters`**, and **`environmentIdFallback`** encode how each platform sends user agent, authenticates (header vs `auth` query on web), and resolves environment ID when response headers are missing. **`LDCommonClient._makeHttpProperties`**, **`Requestor`**, and **`StreamingDataSource`** now call these APIs instead of inline credential-type checks. > > The unused **`NetworkConfig`** / `restrictedHeaders` duplicate in `common_client` is removed. **`authQueryParameters`** is defined for upcoming FDv2 consumers; FDv1 paths are unchanged in behavior. > > Tests add direct **`CredentialConfig`** coverage for io and web, plus streaming tests for environment ID from `x-ld-envid` vs platform fallback. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 5a4c9b4. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 17f7b4e commit c677c3d

8 files changed

Lines changed: 159 additions & 41 deletions

File tree

packages/common_client/lib/src/config/defaults/io_config.dart

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,6 @@ class DefaultEventPaths {
3131
}
3232
}
3333

34-
class NetworkConfig {
35-
Set<String> get restrictedHeaders => {};
36-
}
37-
3834
final class DefaultEndpoints {
3935
final String polling = 'https://clientsdk.launchdarkly.com';
4036
final String streaming = 'https://clientstream.launchdarkly.com';
@@ -43,6 +39,19 @@ final class DefaultEndpoints {
4339

4440
final class CredentialConfig {
4541
CredentialType get credentialType => CredentialType.mobileKey;
42+
43+
/// Headers applied to every request. Every service on this platform
44+
/// authenticates with the authorization header.
45+
Map<String, String> baseHeaders(String credential, String userAgent) =>
46+
{'user-agent': userAgent, 'authorization': credential};
47+
48+
/// Every transport on this platform supports custom headers, so no
49+
/// query parameter authentication is needed.
50+
Map<String, String> authQueryParameters(String credential) => const {};
51+
52+
/// A mobile key does not identify an environment; the environment ID
53+
/// comes only from response headers.
54+
String? environmentIdFallback(String credential) => null;
4655
}
4756

4857
final class DefaultDataSourceConfig {

packages/common_client/lib/src/config/defaults/js_config.dart

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,6 @@ class DefaultEventPaths {
3131
}
3232
}
3333

34-
class NetworkConfig {
35-
Set<String> get restrictedHeaders => {'user-agent', 'authorization'};
36-
}
37-
3834
final class DefaultEndpoints {
3935
final String polling = 'https://clientsdk.launchdarkly.com';
4036
final String streaming = 'https://clientstream.launchdarkly.com';
@@ -43,6 +39,28 @@ final class DefaultEndpoints {
4339

4440
final class CredentialConfig {
4541
CredentialType get credentialType => CredentialType.clientSideId;
42+
43+
/// Headers applied to every request. The user agent is sent under a
44+
/// vendor header because browsers forbid setting `user-agent`. The
45+
/// authorization header is intentionally absent: a browser only
46+
/// delivers it when the service's CORS pre-flight response lists it
47+
/// as an allowed header, so authentication uses the `auth` query
48+
/// parameter instead, which is not subject to that allow-list.
49+
Map<String, String> baseHeaders(String credential, String userAgent) =>
50+
{'x-launchdarkly-user-agent': userAgent};
51+
52+
/// Authentication for every data acquisition request in the browser.
53+
/// The `auth` query parameter is used at all times rather than the
54+
/// authorization header: the header depends on each service's CORS
55+
/// pre-flight allowing it (a missed allow-list entry silently breaks
56+
/// authentication), and the browser's native EventSource cannot send
57+
/// custom headers at all.
58+
Map<String, String> authQueryParameters(String credential) =>
59+
{'auth': credential};
60+
61+
/// A client-side ID identifies the environment directly, so it serves
62+
/// as the environment ID when response headers are unavailable.
63+
String? environmentIdFallback(String credential) => credential;
4664
}
4765

4866
final class DefaultDataSourceConfig {

packages/common_client/lib/src/config/defaults/stub_config.dart

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,6 @@ class DefaultEventPaths {
3131
}
3232
}
3333

34-
class NetworkConfig {
35-
Set<String> get restrictedHeaders => throw Exception('Stub implementation');
36-
}
37-
3834
final class DefaultEndpoints {
3935
DefaultEndpoints() {
4036
throw Exception('Stub implementation');
@@ -47,6 +43,15 @@ final class DefaultEndpoints {
4743

4844
final class CredentialConfig {
4945
CredentialType get credentialType => throw Exception('Stub implementation');
46+
47+
Map<String, String> baseHeaders(String credential, String userAgent) =>
48+
throw Exception('Stub implementation');
49+
50+
Map<String, String> authQueryParameters(String credential) =>
51+
throw Exception('Stub implementation');
52+
53+
String? environmentIdFallback(String credential) =>
54+
throw Exception('Stub implementation');
5055
}
5156

5257
final class DefaultDataSourceConfig {

packages/common_client/lib/src/data_sources/requestor.dart

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import 'package:http/http.dart' as http;
44
import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart';
55

66
import '../config/data_source_config.dart';
7-
import '../config/defaults/credential_type.dart';
87
import '../config/defaults/default_config.dart';
98
import 'data_source.dart';
109
import 'data_source_status.dart';
@@ -96,15 +95,8 @@ final class Requestor {
9695
_lastEtag = etag;
9796
}
9897

99-
var environmentId = getEnvironmentId(res.headers);
100-
101-
if (environmentId == null &&
102-
DefaultConfig.credentialConfig.credentialType ==
103-
CredentialType.clientSideId) {
104-
// When using a client-side ID we can use it to represent the
105-
// environment.
106-
environmentId = _credential;
107-
}
98+
final environmentId = getEnvironmentId(res.headers) ??
99+
DefaultConfig.credentialConfig.environmentIdFallback(_credential);
108100

109101
return DataEvent('put', res.body, environmentId: environmentId);
110102
}

packages/common_client/lib/src/data_sources/streaming_data_source.dart

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart';
55
import 'package:launchdarkly_event_source_client/launchdarkly_event_source_client.dart';
66

77
import '../config/data_source_config.dart';
8-
import '../config/defaults/credential_type.dart';
98
import '../config/defaults/default_config.dart';
109
import 'data_source.dart';
1110
import 'data_source_status.dart';
@@ -184,11 +183,9 @@ final class StreamingDataSource implements DataSource {
184183
_logger.debug('Received connect event, data: ${event.headers}');
185184
if (event.headers != null) {
186185
_environmentId = getEnvironmentId(event.headers);
187-
} else if (DefaultConfig.credentialConfig.credentialType ==
188-
CredentialType.clientSideId) {
189-
// When using a client-side ID we can use it to represent the
190-
// environment.
191-
_environmentId = _credential;
186+
} else {
187+
_environmentId = DefaultConfig.credentialConfig
188+
.environmentIdFallback(_credential);
192189
}
193190
}
194191
})

packages/common_client/lib/src/ld_common_client.dart

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -314,15 +314,8 @@ final class LDCommonClient {
314314

315315
final userAgentString = '${_sdkData.name}/${_sdkData.version}';
316316

317-
switch (DefaultConfig.credentialConfig.credentialType) {
318-
case CredentialType.mobileKey:
319-
additionalHeaders['user-agent'] = userAgentString;
320-
// When using a mobile key the credential appears in the headers. For
321-
// a client-side ID the credential is in the URL.
322-
additionalHeaders['authorization'] = _config.sdkCredential;
323-
case CredentialType.clientSideId:
324-
additionalHeaders['x-launchdarkly-user-agent'] = userAgentString;
325-
}
317+
additionalHeaders.addAll(DefaultConfig.credentialConfig
318+
.baseHeaders(_config.sdkCredential, userAgentString));
326319

327320
return _config.httpProperties.withHeaders(additionalHeaders);
328321
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import 'package:launchdarkly_common_client/src/config/defaults/io_config.dart'
2+
as io;
3+
import 'package:launchdarkly_common_client/src/config/defaults/js_config.dart'
4+
as js;
5+
import 'package:test/test.dart';
6+
7+
void main() {
8+
group('io CredentialConfig', () {
9+
final config = io.CredentialConfig();
10+
11+
test('base headers carry the user agent and the credential', () {
12+
expect(
13+
config.baseHeaders('the-mobile-key', 'Sdk/1.0'),
14+
equals({
15+
'user-agent': 'Sdk/1.0',
16+
'authorization': 'the-mobile-key',
17+
}));
18+
});
19+
20+
test('no auth query parameters; every transport supports headers', () {
21+
expect(config.authQueryParameters('the-mobile-key'), isEmpty);
22+
});
23+
24+
test(
25+
'no environment ID fallback; a mobile key does not identify an '
26+
'environment', () {
27+
expect(config.environmentIdFallback('the-mobile-key'), isNull);
28+
});
29+
});
30+
31+
group('web CredentialConfig', () {
32+
final config = js.CredentialConfig();
33+
34+
test(
35+
'base headers carry the vendor user agent and no authorization '
36+
'header', () {
37+
expect(
38+
config.baseHeaders('the-client-side-id', 'Sdk/1.0'),
39+
equals({
40+
'x-launchdarkly-user-agent': 'Sdk/1.0',
41+
}),
42+
reason: 'browsers only deliver the authorization header when the '
43+
'service CORS pre-flight allows it, so web requests '
44+
'authenticate with the auth query parameter instead');
45+
});
46+
47+
test('the auth query parameter authenticates browser requests', () {
48+
expect(config.authQueryParameters('the-client-side-id'),
49+
equals({'auth': 'the-client-side-id'}));
50+
});
51+
52+
test('the client-side ID serves as the environment ID fallback', () {
53+
expect(config.environmentIdFallback('the-client-side-id'),
54+
equals('the-client-side-id'));
55+
});
56+
});
57+
}

packages/common_client/test/data_sources/streaming_data_source_test.dart

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// ignore_for_file: close_sinks
22

33
import 'dart:async';
4+
import 'dart:collection';
45

56
import 'package:http/testing.dart';
67
import 'package:launchdarkly_common_client/launchdarkly_common_client.dart';
@@ -18,7 +19,7 @@ import 'package:http/http.dart' as http;
1819
import 'package:test/test.dart';
1920

2021
class MockSseClient implements SSEClient {
21-
final Stream<MessageEvent> mockStream;
22+
final Stream<Event> mockStream;
2223

2324
MockSseClient(this.mockStream);
2425

@@ -29,7 +30,7 @@ class MockSseClient implements SSEClient {
2930
void restart() {}
3031

3132
@override
32-
Stream<MessageEvent> get stream => mockStream;
33+
Stream<Event> get stream => mockStream;
3334

3435
@override
3536
bool hasCapability(SSECapability capability) => true;
@@ -39,7 +40,7 @@ class MockSseClient implements SSEClient {
3940
StreamingDataSource,
4041
FlagManager,
4142
DataSourceStatusManager
42-
) makeDataSourceForTest(Stream<MessageEvent> mockStream,
43+
) makeDataSourceForTest(Stream<Event> mockStream,
4344
{LDContext? inContext,
4445
HttpProperties? inProperties,
4546
bool useReport = false,
@@ -85,7 +86,8 @@ class MockSseClient implements SSEClient {
8586
streaming.events.asyncMap((event) async {
8687
switch (event) {
8788
case DataEvent():
88-
return eventHandler.handleMessage(context, event.type, event.data);
89+
return eventHandler.handleMessage(context, event.type, event.data,
90+
environmentId: event.environmentId);
8991
case StatusEvent():
9092
if (event.statusCode != null) {
9193
statusManager.setErrorResponse(event.statusCode!, event.message,
@@ -498,4 +500,49 @@ void main() {
498500
expect(actualMethod, 'REPORT');
499501
});
500502
});
503+
504+
group('environment ID from the stream connection', () {
505+
test('it uses the environment ID response header', () async {
506+
final controller = StreamController<Event>();
507+
final (dataSource, flagManager, _) =
508+
makeDataSourceForTest(controller.stream);
509+
dataSource.start();
510+
511+
controller.sink.add(OpenEvent(
512+
headers: UnmodifiableMapView({'x-ld-envid': 'env-from-header'})));
513+
controller.sink.add(MessageEvent(
514+
'put',
515+
'{"my-boolean-flag": '
516+
'{"version":11,"value":true,"variation":0,"trackEvents":false}}',
517+
null));
518+
for (var i = 0; i < 5; i++) {
519+
await Future<void>.delayed(Duration.zero);
520+
}
521+
522+
expect(flagManager.environmentId, 'env-from-header');
523+
});
524+
525+
test(
526+
'it uses the platform environment ID fallback when the connection '
527+
'provides no headers', () async {
528+
final controller = StreamController<Event>();
529+
final (dataSource, flagManager, _) =
530+
makeDataSourceForTest(controller.stream);
531+
dataSource.start();
532+
533+
controller.sink.add(OpenEvent(headers: null));
534+
controller.sink.add(MessageEvent(
535+
'put',
536+
'{"my-boolean-flag": '
537+
'{"version":11,"value":true,"variation":0,"trackEvents":false}}',
538+
null));
539+
for (var i = 0; i < 5; i++) {
540+
await Future<void>.delayed(Duration.zero);
541+
}
542+
543+
// A mobile key does not identify an environment, so the fallback
544+
// is null on this platform.
545+
expect(flagManager.environmentId, isNull);
546+
});
547+
});
501548
}

0 commit comments

Comments
 (0)