Skip to content
Merged
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
125 changes: 125 additions & 0 deletions lib/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,131 @@ describe('sdk', () => {
});
});

describe('license handshake', () => {
const setupMocks = (licenseGet: jest.Mock) => {
jest.resetModules();
const createCoreJs = jest.fn(() => ({}));
const createHttpClient = jest.fn();

jest.doMock('@descope/core-js-sdk', () => ({
__esModule: true,
default: createCoreJs,
createHttpClient,
wrapWith: (sdkInstance: object) => sdkInstance,
addHooksToConfig: (config, hooks) => {
// eslint-disable-next-line no-param-reassign
config.hooks = hooks;
return config;
},
}));
jest.doMock('./management', () => ({
__esModule: true,
default: () => ({}),
}));
jest.doMock('./management/license', () => ({
__esModule: true,
default: () => ({ get: licenseGet }),
}));

return { createCoreJs, createHttpClient };
};

const getMgmtBeforeRequest = (createHttpClient: jest.Mock) => {
const mgmtConfig = createHttpClient.mock.calls[0][0];
return mgmtConfig.hooks.beforeRequest[0];
};

const flushPromises = () =>
new Promise<void>((resolve) => {
setImmediate(resolve);
});

it('should skip handshake when no management key', () => {
const licenseGet = jest.fn();
setupMocks(licenseGet);
const createNodeSdk = require('.').default; // eslint-disable-line

createNodeSdk({ projectId: 'project-id' });

expect(licenseGet).not.toHaveBeenCalled();
});

it('should inject x-descope-license header after handshake resolves', async () => {
const licenseGet = jest.fn().mockResolvedValue({
ok: true,
data: { rateLimitTier: 'tier3' },
});
const { createHttpClient } = setupMocks(licenseGet);
const createNodeSdk = require('.').default; // eslint-disable-line

createNodeSdk({ projectId: 'project-id', managementKey: 'mk' });

expect(licenseGet).toHaveBeenCalled();
await flushPromises();

const beforeRequest = getMgmtBeforeRequest(createHttpClient);
const result = beforeRequest({ url: 'test' });
expect(result.headers).toEqual({ 'x-descope-license': 'tier3' });
expect(result.token).toBe('mk');
});

it('should not inject header before handshake resolves', () => {
const licenseGet = jest.fn().mockReturnValue(new Promise(() => {}));
const { createHttpClient } = setupMocks(licenseGet);
const createNodeSdk = require('.').default; // eslint-disable-line

createNodeSdk({ projectId: 'project-id', managementKey: 'mk' });

const beforeRequest = getMgmtBeforeRequest(createHttpClient);
const result = beforeRequest({ url: 'test' });
expect(result.headers).toBeUndefined();
expect(result.token).toBe('mk');
});

it('should not inject header when response is not ok', async () => {
const licenseGet = jest.fn().mockResolvedValue({ ok: false, data: undefined });
const { createHttpClient } = setupMocks(licenseGet);
const createNodeSdk = require('.').default; // eslint-disable-line

createNodeSdk({ projectId: 'project-id', managementKey: 'mk' });
await flushPromises();

const beforeRequest = getMgmtBeforeRequest(createHttpClient);
const result = beforeRequest({ url: 'test' });
expect(result.headers).toBeUndefined();
});

it('should log a warning when handshake rejects', async () => {
const err = new Error('boom');
const licenseGet = jest.fn().mockRejectedValue(err);
const warnLogger = { warn: jest.fn() };
const { createHttpClient } = setupMocks(licenseGet);
const createNodeSdk = require('.').default; // eslint-disable-line

createNodeSdk({
projectId: 'project-id',
managementKey: 'mk',
logger: warnLogger,
});
await flushPromises();

expect(warnLogger.warn).toHaveBeenCalledWith('License handshake failed', err);

const beforeRequest = getMgmtBeforeRequest(createHttpClient);
const result = beforeRequest({ url: 'test' });
expect(result.headers).toBeUndefined();
});

it('should not throw when handshake rejects and logger is undefined', async () => {
const licenseGet = jest.fn().mockRejectedValue(new Error('boom'));
setupMocks(licenseGet);
const createNodeSdk = require('.').default; // eslint-disable-line

expect(() => createNodeSdk({ projectId: 'project-id', managementKey: 'mk' })).not.toThrow();
await flushPromises();
});
});

describe('public key', () => {
it('should headers to request', async () => {
const { publicKey, privateKey } = await generateKeyPair('ES384');
Expand Down
29 changes: 29 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
withCookie,
} from './helpers';
import withManagement from './management';
import withLicense from './management/license';
import { AuthenticationInfo, IDPResponse, RefreshAuthenticationInfo, VerifyOptions } from './types';
import descopeErrors from './errors';

Expand Down Expand Up @@ -115,6 +116,11 @@ const nodeSdk = ({
);
};

// Rate limit tier from the license handshake. Populated asynchronously on init
// and injected into the x-descope-license header on every management request
// to apply the correct rate-limit bucket for the project's company tier.
let rateLimitTier: string | undefined;

const mgmtSdkConfig = {
fetch,
...config,
Expand All @@ -131,6 +137,13 @@ const nodeSdk = ({
(requestConfig: RequestConfig) => {
// eslint-disable-next-line no-param-reassign
requestConfig.token = managementKey;
if (rateLimitTier) {
// eslint-disable-next-line no-param-reassign
requestConfig.headers = {
...requestConfig.headers,
'x-descope-license': rateLimitTier,
Comment thread
orius123 marked this conversation as resolved.
};
}
return requestConfig;
},
].concat(config.hooks?.beforeRequest || []),
Expand All @@ -144,6 +157,22 @@ const nodeSdk = ({
headers: nodeHeaders,
});

// Fire-and-forget license handshake. Backend skips license-header validation
// for the GetLicense endpoint itself, so this initial request is safe even
// before the tier is cached.
if (managementKey) {
Comment thread
orius123 marked this conversation as resolved.
withLicense(mgmtHttpClient)
.get()
.then((resp) => {
if (resp.ok && resp.data?.rateLimitTier) {
rateLimitTier = resp.data.rateLimitTier;
Comment thread
orius123 marked this conversation as resolved.
}
})
.catch((e) => {
logger?.warn?.('License handshake failed', e);
});
}
Comment thread
orius123 marked this conversation as resolved.

const sdk = {
...coreSdk,

Expand Down
42 changes: 42 additions & 0 deletions lib/management/license.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { SdkResponse } from '@descope/core-js-sdk';
import withLicense from './license';
import apiPaths from './paths';
import { mockHttpClient, resetMockHttpClient } from './testutils';
import { License } from './types';

const licenseApi = withLicense(mockHttpClient);

const mockLicense: License = {
rateLimitTier: 'tier4',
};

describe('Management License', () => {
afterEach(() => {
jest.clearAllMocks();
resetMockHttpClient();
});

describe('get', () => {
it('should send the correct request and receive correct response', async () => {
const httpResponse = {
ok: true,
json: () => mockLicense,
clone: () => ({
json: () => Promise.resolve(mockLicense),
}),
status: 200,
};
mockHttpClient.get.mockResolvedValue(httpResponse);

const resp: SdkResponse<License> = await licenseApi.get();

expect(mockHttpClient.get).toHaveBeenCalledWith(apiPaths.license.get);
expect(resp).toEqual({
code: 200,
data: mockLicense,
ok: true,
response: httpResponse,
});
});
});
});
10 changes: 10 additions & 0 deletions lib/management/license.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { SdkResponse, transformResponse, HttpClient } from '@descope/core-js-sdk';
import apiPaths from './paths';
import { License } from './types';

const withLicense = (httpClient: HttpClient) => ({
get: (): Promise<SdkResponse<License>> =>
transformResponse<License>(httpClient.get(apiPaths.license.get)),
});

export default withLicense;
3 changes: 3 additions & 0 deletions lib/management/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,4 +214,7 @@ export default {
delete: '/v1/mgmt/managementkey/delete',
search: '/v1/mgmt/managementkey/search',
},
license: {
get: '/v1/mgmt/license',
},
};
4 changes: 4 additions & 0 deletions lib/management/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1203,3 +1203,7 @@ export type MgmtKeyCreateResponse = {
key: MgmtKey;
cleartext: string;
};

export type License = {
rateLimitTier: string;
};
Loading