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
16 changes: 13 additions & 3 deletions examples/es6/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,20 @@ const returnOK = (res: Response, out: SdkResponse<ResponseData>) => {
* @param options any options to put on the cookie like cookieDomain, cookieMaxAge, cookiePath
* @returns Cookie string with all options on the string
*/
const generateCookie = (name: string, value: string, options?: Record<string, string | number>) =>
`${name}=${value}; Domain=${options?.cookieDomain || ''}; Max-Age=${
const generateCookie = (
name: string,
value: string,
options?: Record<string, string | number | boolean>,
) => {
// Secure flag should default to true (secure by default)
// Allow explicit override for local development via secureCookie option
const isSecure = options?.secureCookie !== false && process.env.NODE_ENV !== 'development';
const secureFlag = isSecure ? '; Secure' : '';

return `${name}=${value}; Domain=${options?.cookieDomain || ''}; Max-Age=${
options?.cookieMaxAge || ''
}; Path=${options?.cookiePath || '/'}; HttpOnly; SameSite=Strict`;
}; Path=${options?.cookiePath || '/'}; HttpOnly; SameSite=Strict${secureFlag}`;
};

const returnCookies = <T extends ResponseData>(res: Response, out: SdkResponse<T>) => {
if (out.ok) {
Expand Down
2 changes: 1 addition & 1 deletion jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module.exports = {

collectCoverage: true,
coverageDirectory: 'coverage',
collectCoverageFrom: ['lib/**/*.{js,jsx,ts,tsx}'],
collectCoverageFrom: ['lib/**/*.{js,jsx,ts,tsx}', '!lib/fetch-polyfill.ts'],
coverageThreshold: {
global: {
branches: 74,
Expand Down
40 changes: 36 additions & 4 deletions lib/fetch-polyfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,53 @@ import { fetch as crossFetch, Headers } from 'cross-fetch';

globalThis.Headers ??= Headers;

const highWaterMarkMb = 1024 * 1024 * 30; // 30MB
// Reduced from 30MB to 1MB to prevent memory exhaustion attacks
const highWaterMarkBytes = 1024 * 1024; // 1MB

Comment thread
omercnet marked this conversation as resolved.
// we are increasing the response buffer size due to an issue where node-fetch hangs when response is too big
// Default timeout of 30 seconds to prevent indefinite hangs and Slowloris-style DoS
const DEFAULT_TIMEOUT_MS = 30000;

// we explicitly set the response buffer highWaterMark (1MB) to avoid node-fetch hanging on large responses while still bounding memory usage
const patchedFetch = (...args: Parameters<typeof crossFetch>) => {
// we can get Request on the first arg, or RequestInfo on the second arg
// we want to make sure we are setting the "highWaterMark" so we are doing it on both args
args.forEach((arg) => {
// Updated to only apply highWaterMark to objects, as it can't be applied to strings (it breaks it)
if (arg && typeof arg === 'object') {
// eslint-disable-next-line no-param-reassign, @typescript-eslint/no-unused-expressions
(arg as any).highWaterMark ??= highWaterMarkMb;
(arg as any).highWaterMark ??= highWaterMarkBytes;
}
});

return crossFetch(...args);
// Create an AbortController for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);

// Add signal to fetch options if not already present
const [input, init] = args;
const fetchInit = (init || {}) as RequestInit;

// If a user-provided signal exists, propagate its abort to our controller
const userSignal = fetchInit.signal as AbortSignal | undefined;
if (userSignal) {
if (userSignal.aborted) {
controller.abort();
} else {
userSignal.addEventListener('abort', () => controller.abort(), { once: true });
}
}
// Always use our controller's signal so the default timeout is enforced
fetchInit.signal = controller.signal;

Comment thread
omercnet marked this conversation as resolved.
return crossFetch(input, fetchInit)
Comment thread
omercnet marked this conversation as resolved.
.finally(() => clearTimeout(timeoutId))
.catch((error) => {
// Provide a clearer error message for timeout
if (error.name === 'AbortError') {
throw new Error('Request timeout exceeded');
}
throw error;
});
};

export default patchedFetch as unknown as typeof fetch;
19 changes: 15 additions & 4 deletions lib/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,24 @@ import { AuthenticationInfo } from './types';
* Generate a cookie string from given parameters
* @param name name of the cookie
* @param value value of cookie that must be already encoded
* @param options any options to put on the cookie like cookieDomain, cookieMaxAge, cookiePath
* @param options any options to put on the cookie like cookieDomain, cookieMaxAge, cookiePath, secureCookie
* @returns Cookie string with all options on the string
*/
const generateCookie = (name: string, value: string, options?: Record<string, string | number>) =>
`${name}=${value}; Domain=${options?.cookieDomain || ''}; Max-Age=${
const generateCookie = (
name: string,
value: string,
options?: Record<string, string | number | boolean>,
) => {
// Secure flag should default to true (secure by default)
// Allow explicit override for local development via secureCookie option
// Can be disabled with secureCookie: false or NODE_ENV=development
const isSecure = options?.secureCookie !== false && process.env.NODE_ENV !== 'development';
const secureFlag = isSecure ? '; Secure' : '';

return `${name}=${value}; Domain=${options?.cookieDomain || ''}; Max-Age=${
options?.cookieMaxAge || ''
}; Path=${options?.cookiePath || '/'}; HttpOnly; SameSite=Strict`;
}; Path=${options?.cookiePath || '/'}; HttpOnly; SameSite=Strict${secureFlag}`;
};

/**
* Parse the cookie string and return the value of the cookie
Expand Down
30 changes: 15 additions & 15 deletions lib/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,12 @@ describe('sdk', () => {
// Calling with an audience should enforce aud claim; current implementation ignores it.
await expect(
(sdk as any).validateSession(validToken, { audience: 'expected-aud' }),
).rejects.toThrow('session validation failed');
).rejects.toThrow('Session validation failed');
});

it('should reject when audience mismatches in token for validateSession', async () => {
await expect((sdk as any).validateSession(tokenAudA, { audience: 'aud-b' })).rejects.toThrow(
'session validation failed',
'Session validation failed',
);
});

Expand All @@ -179,7 +179,7 @@ describe('sdk', () => {
} as SdkResponse<JWTResponse>);

await expect((sdk as any).refreshSession(validToken, { audience: 'aud-a' })).rejects.toThrow(
'refresh token validation failed',
'Refresh token validation failed',
);
expect(spyRefresh).toHaveBeenCalledWith(validToken);
});
Expand All @@ -192,7 +192,7 @@ describe('sdk', () => {

await expect(
(sdk as any).validateAndRefreshSession(expiredTokenAudA, validToken, { audience: 'aud-a' }),
).rejects.toThrow('refresh token validation failed');
).rejects.toThrow('Refresh token validation failed');
expect(spyRefresh).toHaveBeenCalledWith(validToken);
});

Expand Down Expand Up @@ -244,7 +244,7 @@ describe('sdk', () => {
});
});
it('should throw an error when session token expired', async () => {
await expect(sdk.validateSession(expiredToken)).rejects.toThrow('session validation failed');
await expect(sdk.validateSession(expiredToken)).rejects.toThrow('Session validation failed');
});
});

Expand All @@ -256,7 +256,7 @@ describe('sdk', () => {
});
it('should throw an error when refresh token expired', async () => {
await expect(sdk.refreshSession(expiredToken)).rejects.toThrow(
'refresh token validation failed',
'Refresh token validation failed',
);
});
it('should refresh session token when refresh token is valid', async () => {
Expand Down Expand Up @@ -318,7 +318,7 @@ describe('sdk', () => {
} as unknown as SdkResponse<JWTResponse>);

await expect(sdk.refreshSession(validToken)).rejects.toThrow(
'refresh token validation failed',
'Refresh token validation failed',
);
expect(spyRefresh).toHaveBeenCalledWith(validToken);
});
Expand All @@ -328,7 +328,7 @@ describe('sdk', () => {
} as SdkResponse<JWTResponse>);

await expect(sdk.refreshSession(validToken)).rejects.toThrow(
'refresh token validation failed',
'Refresh token validation failed',
);
expect(spyRefresh).toHaveBeenCalledWith(validToken);
});
Expand Down Expand Up @@ -367,7 +367,7 @@ describe('sdk', () => {
});
it('should throw an error when both refresh & session tokens expired', async () => {
await expect(sdk.validateAndRefreshSession(expiredToken, expiredToken)).rejects.toThrow(
'refresh token validation failed',
'Refresh token validation failed',
);
});
it('should refresh session token when it expired and refresh token is valid', async () => {
Expand Down Expand Up @@ -423,14 +423,14 @@ describe('sdk', () => {
describe('exchangeAccessKey', () => {
it('should fail when the server call throws', async () => {
const spyExchange = jest.spyOn(sdk.accessKey, 'exchange').mockRejectedValueOnce('error');
await expect(sdk.exchangeAccessKey('key')).rejects.toThrow('could not exchange access key');
await expect(sdk.exchangeAccessKey('key')).rejects.toThrow('Could not exchange access key');
expect(spyExchange).toHaveBeenCalledWith('key', undefined);
});
it('should fail when getting an unexpected response from the server', async () => {
const spyExchange = jest
.spyOn(sdk.accessKey, 'exchange')
.mockResolvedValueOnce({ ok: true, data: {} } as SdkResponse<ExchangeAccessKeyResponse>);
await expect(sdk.exchangeAccessKey('key')).rejects.toThrow('could not exchange access key');
await expect(sdk.exchangeAccessKey('key')).rejects.toThrow('Could not exchange access key');
expect(spyExchange).toHaveBeenCalledWith('key', undefined);
});
it('should fail when getting an error response from the server', async () => {
Expand All @@ -441,7 +441,7 @@ describe('sdk', () => {
},
} as SdkResponse<ExchangeAccessKeyResponse>);
await expect(sdk.exchangeAccessKey('key')).rejects.toThrow(
'could not exchange access key - error-1',
'Could not exchange access key - error-1',
);
expect(spyExchange).toHaveBeenCalledWith('key', undefined);
});
Expand All @@ -450,7 +450,7 @@ describe('sdk', () => {
ok: true,
data: { sessionJwt: expiredToken },
} as SdkResponse<ExchangeAccessKeyResponse>);
await expect(sdk.exchangeAccessKey('key')).rejects.toThrow('could not exchange access key');
await expect(sdk.exchangeAccessKey('key')).rejects.toThrow('Could not exchange access key');
expect(spyExchange).toHaveBeenCalledWith('key', undefined);
});
it('should return the same session token it got from the server', async () => {
Expand Down Expand Up @@ -484,7 +484,7 @@ describe('sdk', () => {
data: { sessionJwt: tokenAudB },
} as SdkResponse<ExchangeAccessKeyResponse>);
await expect(sdk.exchangeAccessKey('key', undefined, { audience: 'aud-a' })).rejects.toThrow(
'could not exchange access key - failed to validate jwt',
'Could not exchange access key - failed to validate JWT',
);
expect(spyExchange).toHaveBeenCalledWith('key', undefined);
});
Expand Down Expand Up @@ -603,7 +603,7 @@ describe('sdk', () => {
data: {
...data,
cookies: [
`${refreshTokenCookieName}=${data.refreshJwt}; Domain=; Max-Age=; Path=/; HttpOnly; SameSite=Strict`,
`${refreshTokenCookieName}=${data.refreshJwt}; Domain=; Max-Age=; Path=/; HttpOnly; SameSite=Strict; Secure`,
],
},
}),
Expand Down
14 changes: 7 additions & 7 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ const nodeSdk = ({
};
} catch (e) {
logger?.error('Failed to parse the provided public key', e);
throw new Error(`Failed to parse public key. Error: ${e}`);
throw new Error('Failed to parse public key');
}
}

Expand Down Expand Up @@ -216,7 +216,7 @@ const nodeSdk = ({
} catch (error) {
/* istanbul ignore next */
logger?.error('session validation failed', error);
throw Error(`session validation failed. Error: ${error}`);
throw Error('Session validation failed');
}
},

Expand Down Expand Up @@ -258,7 +258,7 @@ const nodeSdk = ({
} catch (refreshTokenErr) {
/* istanbul ignore next */
logger?.error('refresh token validation failed', refreshTokenErr);
throw Error(`refresh token validation failed, Error: ${refreshTokenErr}`);
throw Error('Refresh token validation failed');
}
},

Expand Down Expand Up @@ -306,26 +306,26 @@ const nodeSdk = ({
resp = await sdk.accessKey.exchange(accessKey, loginOptions);
} catch (error) {
logger?.error('failed to exchange access key', error);
throw Error(`could not exchange access key - Failed to exchange. Error: ${error}`);
throw Error('Could not exchange access key');
}

if (!resp.ok) {
logger?.error('failed to exchange access key', resp.error);
throw Error(`could not exchange access key - ${resp.error?.errorMessage}`);
throw Error(`Could not exchange access key - ${resp.error?.errorMessage}`);
}

const { sessionJwt } = resp.data;
if (!sessionJwt) {
logger?.error('failed to parse exchange access key response');
throw Error('could not exchange access key');
throw Error('Could not exchange access key');
}

try {
const token = await sdk.validateJwt(sessionJwt, options);
return token;
} catch (error) {
logger?.error('failed to parse jwt from access key', error);
throw Error(`could not exchange access key - failed to validate jwt. Error: ${error}`);
throw Error('Could not exchange access key - failed to validate JWT');
}
},

Expand Down
27 changes: 27 additions & 0 deletions lib/management/jwt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,33 @@ describe('Management JWT', () => {
response: httpResponse,
});
});

it('should reject reserved JWT claims', () => {
expect(() => management.jwt.update('jwt', { iss: 'malicious-issuer' }, 4)).toThrow(
'Cannot override reserved JWT claims: iss',
);

expect(() =>
management.jwt.update('jwt', { sub: 'malicious-sub', exp: 9999999999 }, 4),
).toThrow('Cannot override reserved JWT claims: sub, exp');
});

it('should reject non-object custom claims', () => {
expect(() => management.jwt.update('jwt', 'not-an-object' as any, 4)).toThrow(
'Custom claims must be an object',
);

expect(() => management.jwt.update('jwt', ['array'] as any, 4)).toThrow(
'Custom claims must be an object',
);
});

it('should reject oversized custom claims', () => {
const largeClaims = { data: 'x'.repeat(15000) };
expect(() => management.jwt.update('jwt', largeClaims, 4)).toThrow(
'Custom claims exceed maximum size of 10KB',
);
});
});

describe('impersonate', () => {
Expand Down
Loading
Loading