Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
70 changes: 70 additions & 0 deletions packages/sdk/react-native/__tests__/MobileDataManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,76 @@ describe('given a MobileDataManager with mocked dependencies', () => {
);
});

it('includes the secure mode hash as a query parameter when hash is provided in identify options', async () => {
await mobileDataManager.setConnectionMode('polling');
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false, hash: 'test-hash-abc123' };
const identifyResolve = jest.fn();
const identifyReject = jest.fn();

await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions);

expect(platform.requests.fetch).toHaveBeenCalledWith(
expect.stringContaining('h=test-hash-abc123'),
expect.anything(),
);
});

it('does not include the secure mode hash query parameter when hash is not provided', async () => {
await mobileDataManager.setConnectionMode('polling');
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };
const identifyResolve = jest.fn();
const identifyReject = jest.fn();

await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions);

expect(platform.requests.fetch).toHaveBeenCalledWith(
expect.not.stringContaining('h='),
expect.anything(),
);
});

it('persists the secure mode hash when connection mode changes after identify', async () => {
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false, hash: 'my-secure-hash' };
const identifyResolve = jest.fn();
const identifyReject = jest.fn();

// Identify in streaming mode with a hash
await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions);

// Switch to polling — hash should be forwarded to the new connection
await mobileDataManager.setConnectionMode('polling');

expect(platform.requests.fetch).toHaveBeenCalledWith(
expect.stringContaining('h=my-secure-hash'),
expect.anything(),
);
});

it('clears the secure mode hash when identify is called without a hash after a previous hash was set', async () => {
await mobileDataManager.setConnectionMode('polling');
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
const identifyResolve = jest.fn();
const identifyReject = jest.fn();

// First identify with hash
await mobileDataManager.identify(identifyResolve, identifyReject, context, {
waitForNetworkResults: false,
hash: 'initial-hash',
});

// Second identify without hash — previous hash should not be forwarded
await mobileDataManager.identify(identifyResolve, identifyReject, context, {
waitForNetworkResults: false,
});

const fetchCalls = (platform.requests.fetch as jest.Mock).mock.calls;
const lastCallUrl = fetchCalls[fetchCalls.length - 1][0];
expect(lastCallUrl).not.toContain('h=');
});

it('does not include withReasons query parameter when withReasons is false', async () => {
const withReasonsConfig = { ...config, withReasons: false };
mobileDataManager = new MobileDataManager(
Expand Down
5 changes: 5 additions & 0 deletions packages/sdk/react-native/src/MobileDataManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export default class MobileDataManager extends BaseDataManager {
// Not implemented yet.
protected networkAvailable: boolean = true;
protected connectionMode: ConnectionMode = 'streaming';
protected secureModeHash?: string;

constructor(
platform: Platform,
Expand Down Expand Up @@ -65,6 +66,9 @@ export default class MobileDataManager extends BaseDataManager {
}
this.context = context;

// Capture the secure mode hash (if set), so that it can be forwarded to makeRequestor on each re(connection).
this.secureModeHash = identifyOptions?.hash;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Secure mode hash missing from streaming connection URL

High Severity

The secureModeHash is only passed to makeRequestor (used for polling), but not propagated to the streaming connection. The browser SDK's BrowserDataManager additionally calls this.setConnectionParams({ queryParameters: [{ key: 'h', value: hash }] }) which feeds into createStreamingProcessor via _connectionParams.queryParameters. Without this call, the StreamingProcessor constructs its URI without the h= query parameter, so secure mode verification fails for streaming connections.

Additional Locations (1)
Fix in Cursor Fix in Web


// When bootstrap is provided, resolve identify immediately then fall through to connect.
if (identifyOptions?.bootstrap) {
this._finishIdentifyFromBootstrap(context, identifyOptions, identifyResolve);
Expand Down Expand Up @@ -137,6 +141,7 @@ export default class MobileDataManager extends BaseDataManager {
[],
this.config.withReasons,
this.config.useReport,
this.secureModeHash,
);

this.updateProcessor?.close();
Expand Down
9 changes: 9 additions & 0 deletions packages/shared/sdk-client/src/api/LDIdentifyOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,13 @@ export interface LDIdentifyOptions {
* @hidden
*/
bootstrapParsed?: { [key: string]: ItemDescriptor };

/**
* The secure mode hash for the context being identified. Used to verify the context
* key on the LaunchDarkly server when secure mode is enabled for the environment.
*
* Generate this hash server-side using the SDK key and the context key.
*/

hash?: string;
}