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
121 changes: 121 additions & 0 deletions lib/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,127 @@ 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: () => ({ license: { 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
28 changes: 28 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,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 so
// Cloudflare can apply the correct rate limit bucket per customer tier.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit, I don't think logs should mention CF specifically, its implementation details

I would also not mention the word "customer" tier but "project's company" tier

let rateLimitTier: string | undefined;

const mgmtSdkConfig = {
fetch,
...config,
Expand All @@ -131,6 +136,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,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

just making sure the Descope deployment already allow this header (if not, requests will start to fail)

};
}
return requestConfig;
},
].concat(config.hooks?.beforeRequest || []),
Expand All @@ -144,6 +156,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) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

is this only for mgmt actions?

also, don't we have mgmt actions that are not bound to mgmt key?

management.license
.get()
.then((resp) => {
if (resp.ok && resp.data?.rateLimitTier) {
rateLimitTier = resp.data.rateLimitTier;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

have we considered to wait with other requests until this one completed?

}
})
.catch((e) => {
logger?.warn?.('License handshake failed', e);
});
}
Comment thread
orius123 marked this conversation as resolved.

const sdk = {
...coreSdk,

Expand Down
2 changes: 2 additions & 0 deletions lib/management/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import withInboundApplication from './inboundapplication';
import withOutboundApplication from './outboundapplication';
import withDescoper from './descoper';
import withManagementKey from './managementKey';
import withLicense from './license';
import { FGAConfig } from './types';

/** Constructs a higher level Management API that wraps the functions from code-js-sdk */
Expand All @@ -43,6 +44,7 @@ const withManagement = (client: HttpClient, fgaConfig?: FGAConfig) => ({
fga: WithFGA(client, fgaConfig),
descoper: withDescoper(client),
managementKey: withManagementKey(client),
license: withLicense(client),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

do we want to expose this API to customers? - imho - no

});

export default withManagement;
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 withManagement from '.';
import apiPaths from './paths';
import { mockHttpClient, resetMockHttpClient } from './testutils';
import { License } from './types';

const management = withManagement(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 management.license.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