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
36 changes: 36 additions & 0 deletions src/client/tokenStore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,4 +330,40 @@ describe('TokenStore', () => {
expect(snapshot.error?.message).toBe('string error');
});
});

describe('refresh schedule buffer', () => {
it('scheduled refresh fires a real refresh while the token is in the isExpiring state', async () => {
const now = Math.floor(Date.now() / 1000);
const initialPayload = {
sub: 'user_123',
sid: 'session_123',
iat: now,
exp: now + 300,
};
const initialToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(initialPayload))}.mock-signature`;

const refreshedPayload = {
sub: 'user_123',
sid: 'session_123',
iat: now,
exp: now + 3600,
};
const refreshedToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(refreshedPayload))}.mock-signature`;

document.cookie = `workos-access-token=${encodeURIComponent(initialToken)}`;
vi.mocked(refreshAccessTokenAction).mockResolvedValue(refreshedToken);

const localStore = new TokenStore();
expect(refreshAccessTokenAction).not.toHaveBeenCalled();

// Advance past the originally scheduled fire time. If the schedule buffer
// and the isExpiring buffer disagree, the timer's callback will see
// isExpiring=false and silently no-op, leaving call count at 0.
await vi.advanceTimersByTimeAsync(300_000);

expect(refreshAccessTokenAction).toHaveBeenCalledTimes(1);

localStore.reset();
});
});
});
43 changes: 27 additions & 16 deletions src/client/tokenStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,19 @@ interface TokenState {
}

const TOKEN_EXPIRY_BUFFER_SECONDS = 60;
const SHORT_TOKEN_LIFETIME_SECONDS = 300;
const SHORT_TOKEN_EXPIRY_BUFFER_SECONDS = 30;
const MIN_REFRESH_DELAY_SECONDS = 15;
const MAX_REFRESH_DELAY_SECONDS = 24 * 60 * 60;
const RETRY_DELAY_SECONDS = 300;
const jwtCookieName = 'workos-access-token';

function getExpiryBuffer(totalTokenLifetime: number): number {
return totalTokenLifetime <= SHORT_TOKEN_LIFETIME_SECONDS
? SHORT_TOKEN_EXPIRY_BUFFER_SECONDS
: TOKEN_EXPIRY_BUFFER_SECONDS;
}

export class TokenStore {
private state: TokenState;
private serverSnapshot: TokenState;
Expand All @@ -35,7 +43,7 @@ export class TokenStore {
this.fastCookieConsumed = true;
const tokenData = this.parseToken(initialToken);
if (tokenData) {
this.scheduleRefresh(tokenData.timeUntilExpiry);
this.scheduleRefresh(tokenData);
}
}
}
Expand Down Expand Up @@ -69,26 +77,33 @@ export class TokenStore {
this.notify();
}

private scheduleRefresh(timeUntilExpiry?: number) {
private scheduleRefresh(tokenData?: { timeUntilExpiry: number; totalTokenLifetime: number }) {
if (this.refreshTimeout) {
clearTimeout(this.refreshTimeout);
this.refreshTimeout = undefined;
}

const delay =
typeof timeUntilExpiry === 'undefined' ? RETRY_DELAY_SECONDS * 1000 : this.getRefreshDelay(timeUntilExpiry);
const delay = tokenData === undefined ? RETRY_DELAY_SECONDS * 1000 : this.getRefreshDelay(tokenData);

this.refreshTimeout = setTimeout(() => {
void this.getAccessTokenSilently().catch(() => {});
}, delay);
}

private getRefreshDelay(timeUntilExpiry: number) {
if (timeUntilExpiry <= TOKEN_EXPIRY_BUFFER_SECONDS) {
private getRefreshDelay({
timeUntilExpiry,
totalTokenLifetime,
}: {
timeUntilExpiry: number;
totalTokenLifetime: number;
}) {
const bufferSeconds = getExpiryBuffer(totalTokenLifetime);

if (timeUntilExpiry <= bufferSeconds) {
return 0;
}

const idealDelay = (timeUntilExpiry - TOKEN_EXPIRY_BUFFER_SECONDS) * 1000;
const idealDelay = (timeUntilExpiry - bufferSeconds) * 1000;

return Math.min(Math.max(idealDelay, MIN_REFRESH_DELAY_SECONDS * 1000), MAX_REFRESH_DELAY_SECONDS * 1000);
}
Expand Down Expand Up @@ -178,21 +193,17 @@ export class TokenStore {
}

const timeUntilExpiry = payload.exp - now;

let bufferSeconds = TOKEN_EXPIRY_BUFFER_SECONDS;
const totalTokenLifetime = payload.exp - (payload.iat || now);
const bufferSeconds = getExpiryBuffer(totalTokenLifetime);

if (totalTokenLifetime <= 300) {
bufferSeconds = 30;
}

const isExpiring = payload.exp < now + bufferSeconds;
const isExpiring = payload.exp <= now + bufferSeconds;

return {
payload,
expiresAt: payload.exp,
isExpiring,
timeUntilExpiry,
totalTokenLifetime,
};
} catch {
return null;
Expand Down Expand Up @@ -240,7 +251,7 @@ export class TokenStore {

const tokenData = this.parseToken(fastToken);
if (tokenData) {
this.scheduleRefresh(tokenData.timeUntilExpiry);
this.scheduleRefresh(tokenData);
}

return fastToken;
Expand Down Expand Up @@ -320,7 +331,7 @@ export class TokenStore {

const tokenData = this.parseToken(token);
if (tokenData) {
this.scheduleRefresh(tokenData.timeUntilExpiry);
this.scheduleRefresh(tokenData);
}

return token;
Expand Down
1 change: 1 addition & 0 deletions src/client/useAccessToken.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ describe('useAccessToken', () => {
expiresAt: 9999999999,
isExpiring: false,
timeUntilExpiry: 3600,
totalTokenLifetime: 3600,
});

vi.mocked(tokenStore.getAccessTokenSilently).mockResolvedValue('test-token');
Expand Down
Loading