Skip to content

Commit

Permalink
feat: Add basic secure mode support for browser SDK. (#598)
Browse files Browse the repository at this point in the history
The secure mode hash is only available in the browser SDK for now, so I
added a general mechanism for passing query parameters through the
layers.

We have plans to make some generic HttpOptions, which may be used in
place of some of the types I have used in this PR.
  • Loading branch information
kinyoklion authored Oct 2, 2024
1 parent 9ad25bd commit 3389983
Show file tree
Hide file tree
Showing 12 changed files with 278 additions and 20 deletions.
103 changes: 92 additions & 11 deletions packages/sdk/browser/__tests__/BrowserDataManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ import {
internal,
LDEmitter,
LDHeaders,
LDIdentifyOptions,
LDLogger,
Platform,
Response,
ServiceEndpoints,
} from '@launchdarkly/js-client-sdk-common';

import BrowserDataManager from '../src/BrowserDataManager';
import { BrowserIdentifyOptions } from '../src/BrowserIdentifyOptions';
import validateOptions, { ValidatedOptions } from '../src/options';
import BrowserEncoding from '../src/platform/BrowserEncoding';
import BrowserInfo from '../src/platform/BrowserInfo';
Expand Down Expand Up @@ -193,7 +193,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
);

const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };
const identifyOptions: BrowserIdentifyOptions = {};
const identifyResolve = jest.fn();
const identifyReject = jest.fn();

Expand All @@ -202,9 +202,91 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
expect(platform.requests.createEventSource).toHaveBeenCalled();
});

it('includes the secure mode hash for streaming requests', async () => {
dataManager = new BrowserDataManager(
platform,
flagManager,
'test-credential',
config,
validateOptions({ streaming: true }, logger),
() => ({
pathGet(encoding: Encoding, _plainContextString: string): string {
return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`;
},
pathReport(_encoding: Encoding, _plainContextString: string): string {
return `/msdk/evalx/context`;
},
}),
() => ({
pathGet(encoding: Encoding, _plainContextString: string): string {
return `/meval/${base64UrlEncode(_plainContextString, encoding)}`;
},
pathReport(_encoding: Encoding, _plainContextString: string): string {
return `/meval`;
},
}),
baseHeaders,
emitter,
diagnosticsManager,
);

const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
const identifyOptions: BrowserIdentifyOptions = { hash: 'potato' };
const identifyResolve = jest.fn();
const identifyReject = jest.fn();

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

expect(platform.requests.createEventSource).toHaveBeenCalledWith(
'/meval/eyJraW5kIjoidXNlciIsImtleSI6InRlc3QtdXNlciJ9?h=potato&withReasons=true',
expect.anything(),
);
});

it('includes secure mode hash for initial poll request', async () => {
dataManager = new BrowserDataManager(
platform,
flagManager,
'test-credential',
config,
validateOptions({ streaming: false }, logger),
() => ({
pathGet(encoding: Encoding, _plainContextString: string): string {
return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`;
},
pathReport(_encoding: Encoding, _plainContextString: string): string {
return `/msdk/evalx/context`;
},
}),
() => ({
pathGet(encoding: Encoding, _plainContextString: string): string {
return `/meval/${base64UrlEncode(_plainContextString, encoding)}`;
},
pathReport(_encoding: Encoding, _plainContextString: string): string {
return `/meval`;
},
}),
baseHeaders,
emitter,
diagnosticsManager,
);

const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
const identifyOptions: BrowserIdentifyOptions = { hash: 'potato' };
const identifyResolve = jest.fn();
const identifyReject = jest.fn();

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

expect(platform.requests.fetch).toHaveBeenCalledWith(
'/msdk/evalx/contexts/eyJraW5kIjoidXNlciIsImtleSI6InRlc3QtdXNlciJ9?withReasons=true&h=potato',
expect.anything(),
);
});

it('should load cached flags and continue to poll to complete identify', async () => {
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };

flagManager.loadCached.mockResolvedValue(true);

let identifyResolve: () => void;
Expand All @@ -216,7 +298,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
identifyReject = jest.fn();

// this is the function under test
dataManager.identify(identifyResolve, identifyReject, context, identifyOptions);
dataManager.identify(identifyResolve, identifyReject, context, {});
});

expect(logger.debug).toHaveBeenCalledWith(
Expand All @@ -234,7 +316,6 @@ describe('given a BrowserDataManager with mocked dependencies', () => {

it('should identify from polling when there are no cached flags', async () => {
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };

let identifyResolve: () => void;
let identifyReject: (err: Error) => void;
Expand All @@ -245,7 +326,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
identifyReject = jest.fn();

// this is the function under test
dataManager.identify(identifyResolve, identifyReject, context, identifyOptions);
dataManager.identify(identifyResolve, identifyReject, context, {});
});

expect(logger.debug).not.toHaveBeenCalledWith(
Expand All @@ -263,7 +344,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {

it('creates a stream when streaming is enabled after construction', async () => {
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };
const identifyOptions: BrowserIdentifyOptions = {};
const identifyResolve = jest.fn();
const identifyReject = jest.fn();

Expand All @@ -278,7 +359,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {

it('does not re-create the stream if it already running', async () => {
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };
const identifyOptions: BrowserIdentifyOptions = {};
const identifyResolve = jest.fn();
const identifyReject = jest.fn();

Expand Down Expand Up @@ -306,7 +387,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {

it('starts a stream on demand when not forced on/off', async () => {
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };
const identifyOptions: BrowserIdentifyOptions = {};
const identifyResolve = jest.fn();
const identifyReject = jest.fn();

Expand All @@ -325,7 +406,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {

it('does not start a stream when forced off', async () => {
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };
const identifyOptions: BrowserIdentifyOptions = {};
const identifyResolve = jest.fn();
const identifyReject = jest.fn();

Expand All @@ -345,7 +426,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {

it('starts streaming on identify if the automatic state is true', async () => {
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };
const identifyOptions: BrowserIdentifyOptions = {};
const identifyResolve = jest.fn();
const identifyReject = jest.fn();

Expand Down
48 changes: 43 additions & 5 deletions packages/sdk/browser/src/BrowserClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,31 @@ import {
LDHeaders,
Platform,
} from '@launchdarkly/js-client-sdk-common';
import { LDIdentifyOptions } from '@launchdarkly/js-client-sdk-common/dist/api/LDIdentifyOptions';
import { EventName } from '@launchdarkly/js-client-sdk-common/dist/LDEmitter';

import BrowserDataManager from './BrowserDataManager';
import { BrowserIdentifyOptions as LDIdentifyOptions } from './BrowserIdentifyOptions';
import GoalManager from './goals/GoalManager';
import { Goal, isClick } from './goals/Goals';
import validateOptions, { BrowserOptions, filterToBaseOptions } from './options';
import BrowserPlatform from './platform/BrowserPlatform';

/**
* We are not supporting dynamically setting the connection mode on the LDClient.
* The SDK does not support offline mode. Instead bootstrap data can be used.
*
* The LaunchDarkly SDK client object.
*
* Applications should configure the client at page load time and reuse the same instance.
*
* For more information, see the [SDK Reference Guide](https://docs.launchdarkly.com/sdk/client-side/javascript).
*
* @ignore Implementation Note: We are not supporting dynamically setting the connection mode on the LDClient.
* @ignore Implementation Note: The SDK does not support offline mode. Instead bootstrap data can be used.
* @ignore Implementation Note: The browser SDK has different identify options, so omits the base implementation
* @ignore from the interface.
*/
export type LDClient = Omit<
CommonClient,
'setConnectionMode' | 'getConnectionMode' | 'getOffline'
'setConnectionMode' | 'getConnectionMode' | 'getOffline' | 'identify'
> & {
/**
* Specifies whether or not to open a streaming connection to LaunchDarkly for live flag updates.
Expand All @@ -40,9 +49,38 @@ export type LDClient = Omit<
* This can also be set as the `streaming` property of {@link LDOptions}.
*/
setStreaming(streaming?: boolean): void;

/**
* Identifies a context to LaunchDarkly.
*
* Unlike the server-side SDKs, the client-side JavaScript SDKs maintain a current context state,
* which is set when you call `identify()`.
*
* Changing the current context also causes all feature flag values to be reloaded. Until that has
* finished, calls to {@link variation} will still return flag values for the previous context. You can
* await the Promise to determine when the new flag values are available.
*
* @param context
* The LDContext object.
* @param identifyOptions
* Optional configuration. Please see {@link LDIdentifyOptions}.
* @returns
* A Promise which resolves when the flag values for the specified
* context are available. It rejects when:
*
* 1. The context is unspecified or has no key.
*
* 2. The identify timeout is exceeded. In client SDKs this defaults to 5s.
* You can customize this timeout with {@link LDIdentifyOptions | identifyOptions}.
*
* 3. A network error is encountered during initialization.
*
* @ignore Implementation Note: Browser implementation has different options.
*/
identify(context: LDContext, identifyOptions?: LDIdentifyOptions): Promise<void>;
};

export class BrowserClient extends LDClientImpl {
export class BrowserClient extends LDClientImpl implements LDClient {
private readonly goalManager?: GoalManager;

constructor(
Expand Down
16 changes: 15 additions & 1 deletion packages/sdk/browser/src/BrowserDataManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
Requestor,
} from '@launchdarkly/js-client-sdk-common';

import { BrowserIdentifyOptions } from './BrowserIdentifyOptions';
import { ValidatedOptions } from './options';

const logTag = '[BrowserDataManager]';
Expand All @@ -24,6 +25,7 @@ export default class BrowserDataManager extends BaseDataManager {
// Otherwise we automatically manage streaming state.
private forcedStreaming?: boolean = undefined;
private automaticStreamingState: boolean = false;
private secureModeHash?: string;

// +-----------+-----------+---------------+
// | forced | automatic | state |
Expand Down Expand Up @@ -70,9 +72,18 @@ export default class BrowserDataManager extends BaseDataManager {
identifyResolve: () => void,
identifyReject: (err: Error) => void,
context: Context,
_identifyOptions?: LDIdentifyOptions,
identifyOptions?: LDIdentifyOptions,
): Promise<void> {
this.context = context;
const browserIdentifyOptions = identifyOptions as BrowserIdentifyOptions | undefined;
if (browserIdentifyOptions?.hash) {
this.setConnectionParams({
queryParameters: [{ key: 'h', value: browserIdentifyOptions.hash }],
});
} else {
this.setConnectionParams();
}
this.secureModeHash = browserIdentifyOptions?.hash;
if (await this.flagManager.loadCached(context)) {
this.debugLog('Identify - Flags loaded from cache. Continuing to initialize via a poll.');
}
Expand Down Expand Up @@ -177,6 +188,9 @@ export default class BrowserDataManager extends BaseDataManager {
if (this.config.withReasons) {
parameters.push({ key: 'withReasons', value: 'true' });
}
if (this.secureModeHash) {
parameters.push({ key: 'h', value: this.secureModeHash });
}

const headers: { [key: string]: string } = { ...this.baseHeaders };
let body;
Expand Down
9 changes: 9 additions & 0 deletions packages/sdk/browser/src/BrowserIdentifyOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { LDIdentifyOptions } from '@launchdarkly/js-client-sdk-common';

export interface BrowserIdentifyOptions extends Omit<LDIdentifyOptions, 'waitForNetworkResults'> {
/**
* The signed context key if you are using [Secure Mode]
* (https://docs.launchdarkly.com/sdk/features/secure-mode#configuring-secure-mode-in-the-javascript-client-side-sdk).
*/
hash?: string;
}
2 changes: 2 additions & 0 deletions packages/sdk/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
// The exported LDClient and LDOptions are the browser specific implementations.
// These shadow the common implementations.
import { BrowserClient, LDClient } from './BrowserClient';
import { BrowserIdentifyOptions as LDIdentifyOptions } from './BrowserIdentifyOptions';
import { BrowserOptions as LDOptions } from './options';

export {
Expand All @@ -32,6 +33,7 @@ export {
LDEvaluationDetail,
LDEvaluationDetailTyped,
LDEvaluationReason,
LDIdentifyOptions,
};

export function init(clientSideId: string, options?: LDOptions): LDClient {
Expand Down
Loading

0 comments on commit 3389983

Please sign in to comment.