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
4 changes: 0 additions & 4 deletions packages/clerk-js/src/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,6 @@ export function clerkMissingFapiClientInResources(): never {
throw new Error(`${errorPrefix} Missing FAPI client in resources.`);
}

export function clerkCoreErrorExpiredToken(expiresAt: number): never {
throw new Error(`${errorPrefix} Token has expired (exp='${expiresAt}').`);
}

export function clerkCoreErrorTokenRefreshFailed(message: string): never {
throw new Error(`${errorPrefix} Token refresh failed (error='${message}')`);
}
Expand Down
16 changes: 6 additions & 10 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,17 +66,13 @@ export class Session extends BaseResource implements SessionResource {
return null;
}

const { leewayInSeconds = 10, template, skipCache = false } = options || {};
if (!template && leewayInSeconds >= 60) {
const { leewayInSeconds, template, skipCache = false } = options || {};
if (!template && Number(leewayInSeconds) >= 60) {
throw new Error('Leeway can not exceed the token lifespan (60 seconds)');
}

if (!!template && this.#isLegacyIntegrationRequest(template)) {
return this.#handleLegacyIntegrationToken({
template,
leewayInSeconds,
skipCache,
});
if (this.#isLegacyIntegrationRequest(template)) {
return this.#handleLegacyIntegrationToken({ template, leewayInSeconds, skipCache });
}

const tokenId = this.#getCacheId(template);
Expand All @@ -91,7 +87,7 @@ export class Session extends BaseResource implements SessionResource {
return tokenResolver.then(res => res.getRawString());
};

#hydrateCache = (token: Token | null) => {
#hydrateCache = (token: TokenResource | null) => {
if (token) {
SessionTokenCache.set({
tokenId: this.#getCacheId(),
Expand All @@ -109,7 +105,7 @@ export class Session extends BaseResource implements SessionResource {
}

#isLegacyIntegrationRequest = (template: string | undefined): boolean => {
return (template || '').startsWith('integration_');
return !!template && template.startsWith('integration_');
};

#removeLegacyIntegrationPrefix = (template: string | undefined): string => {
Expand Down
90 changes: 86 additions & 4 deletions packages/clerk-js/src/core/tokenCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,34 @@ const jwt = jwtGen.sign(
);

describe('MemoryTokenCache', () => {
describe('clear()', () => {
it('removes all entries', () => {
const cache = SessionTokenCache;
const token = new Token({
object: 'token',
id: 'foo',
jwt,
});

const tokenResolver = new Promise<TokenResource>(resolve => setTimeout(() => resolve(token), 100));
const key = {
tokenId: 'foo',
audience: 'bar',
};

// Add a tokenResolver to cache
cache.set({ ...key, tokenResolver });

expect(cache.get(key)).toBeDefined();
expect(cache.size()).toEqual(1);

cache.clear();

expect(cache.get(key)).toBeUndefined();
expect(cache.size()).toEqual(0);
});
});

it('caches tokenResolver while is pending until the JWT expiration', async () => {
const cache = SessionTokenCache;
const token = new Token({
Expand Down Expand Up @@ -77,11 +105,65 @@ describe('MemoryTokenCache', () => {

// Add another tokenResolver to cache
cache.set({ ...key, tokenResolver });
});

// Clear the cache
cache.clear();
describe('get(key, leeway)', () => {
it('includes 5 seconds sync leeway', async () => {
const cache = SessionTokenCache;
const token = new Token({
object: 'token',
id: 'foo',
jwt,
});

// Cache is empty, tokenResolver has been removed due to cache clearance
expect(cache.get(key)).toBeUndefined();
const tokenResolver = new Promise<TokenResource>(resolve => resolve(token));
const key = { tokenId: 'foo', audience: 'bar' };

cache.set({ ...key, tokenResolver });
// Wait tokenResolver to resolve
jest.advanceTimersByTime(100);
await tokenResolver;

expect(cache.get(key)).toMatchObject(key);

// 45s since token created
jest.advanceTimersByTime(45 * 1000);
expect(cache.get(key)).toMatchObject(key);

// 46s since token created
jest.advanceTimersByTime(1 * 1000);
expect(cache.get(key)).toBeUndefined();
});

it('includes 5 seconds sync leeway even if leeway is removed', async () => {
const cache = SessionTokenCache;
const token = new Token({
object: 'token',
id: 'foo',
jwt,
});

const tokenResolver = new Promise<TokenResource>(resolve => resolve(token));
const key = { tokenId: 'foo', audience: 'bar' };

cache.set({ ...key, tokenResolver });
// Wait tokenResolver to resolve
jest.advanceTimersByTime(100);
await tokenResolver;

expect(cache.get(key)).toMatchObject(key);

// 45s since token created
jest.advanceTimersByTime(45 * 1000);
expect(cache.get(key, 0)).toMatchObject(key);

// 54s since token created
jest.advanceTimersByTime(9 * 1000);
expect(cache.get(key, 0)).toMatchObject(key);

// 55s since token created
jest.advanceTimersByTime(1 * 1000);
expect(cache.get(key, 0)).toBeUndefined();
});
});
});
30 changes: 16 additions & 14 deletions packages/clerk-js/src/core/tokenCache.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import type { TokenResource } from '@clerk/types';

import { clerkCoreErrorExpiredToken } from './errors';

interface TokenCacheKeyJSON {
audience?: string;
tokenId: string;
Expand All @@ -11,10 +9,12 @@ interface TokenCacheEntry extends TokenCacheKeyJSON {
tokenResolver: Promise<TokenResource>;
}

type Seconds = number;

interface TokenCacheValue {
entry: TokenCacheEntry;
expiresAt?: number;
expiresIn?: number;
createdAt: Seconds;
expiresIn?: Seconds;
}

interface TokenCache {
Expand All @@ -27,6 +27,8 @@ interface TokenCache {
const KEY_PREFIX = 'clerk';
const DELIMITER = '::';
const LEEWAY = 10;
// This value should have the same value as the INTERVAL_IN_MS in AuthenticationPoller
const SYNC_LEEWAY = 5;
Comment thread
nikosdouvlis marked this conversation as resolved.

export class TokenCacheKey {
static fromKey(key: string): TokenCacheKey {
Expand Down Expand Up @@ -63,7 +65,9 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => {
});

const key = cacheKey.toKey();
const value: TokenCacheValue = { entry };

const createdAt = Math.floor(Date.now() / 1000);
const value: TokenCacheValue = { entry, createdAt };

const deleteKey = () => {
if (cache.get(key) === value) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

馃檭 i'm not a big fun of this check since it compares 2 TokenCacheValue objects and this equality operator is checking them not by value but by reference! I think we could something better here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Hey Dimitri, I might be wrong but I think this is meant to compare by reference, actually, but it's possible that we no longer need it.

Keep in mind that before the setActive PR that we merged yesterday, the keys did not include the updateAt value. The set method schedules a delete call expiresIn seconds after caching the entry, while get might actually delete and cache an entry leeway seconds before the delete timeout is called.

The equality check in essence means that calling deleteKey will only delete the entry that scheduled the call and only that, even if a new entry with the same key was stored afterward.

LMK if this makes sense :)

However, now that we do have the updatedAt in the key, let's think about whether we need to keep the check or not. I think i'd keep it, as the key is produced outside of that class so no one enforces that all entries will have different keys. Ofc, we can always refactor this logic and remove the check. LMK your thoughts

Expand All @@ -73,16 +77,11 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => {

entry.tokenResolver
.then(newToken => {
const nowSeconds = Math.floor(Date.now() / 1000);
const expiresAt = newToken.jwt.claims.exp;
const expiresIn = expiresAt - nowSeconds;

if (expiresIn <= 0) {
clerkCoreErrorExpiredToken(expiresAt);
Comment thread
dimkl marked this conversation as resolved.
}
const issuedAt = newToken.jwt.claims.iat;
const expiresIn: Seconds = expiresAt - issuedAt;

// Mutate cached value and set expirations
value.expiresAt = expiresAt;
value.expiresIn = expiresIn;
setTimeout(deleteKey, expiresIn * 1000);
})
Expand All @@ -102,9 +101,12 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => {
}

const nowSeconds = Math.floor(Date.now() / 1000);
const willExpireSoon = value.expiresAt && value.expiresAt - leeway < nowSeconds;
Comment thread
nikosdouvlis marked this conversation as resolved.
const elapsedSeconds = nowSeconds - value.createdAt;
// We will include the authentication poller interval as part of the leeway to ensure
// that the cache value will be valid for more than the SYNC_LEEWAY or the leeway in the next poll.
const expiresSoon = value.expiresIn! - elapsedSeconds < (leeway || 1) + SYNC_LEEWAY;

if (willExpireSoon) {
if (expiresSoon) {
cache.delete(cacheKey.toKey());
return;
}
Expand Down