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
55 changes: 55 additions & 0 deletions packages/sdks/react-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,61 @@ useEffect(() => {
Descope SDK automatically refreshes the session token when it is about to expire. This is done in the background using the refresh token, without any additional configuration.
If you want to disable this behavior, you can pass `autoRefresh={false}` to the `AuthProvider` component. This will prevent the SDK from automatically refreshing the session token.

### Activity-Based Session Refresh

Pass `autoRefresh={{ whenActive: true }}` to skip refresh calls for idle users. The SDK will only refresh when `sdk.markActive()` has been called since the last refresh. This reduces unnecessary API calls and enables accurate server-side session inactivity tracking.

**Step 1:** Enable it in `AuthProvider`:

```jsx
<AuthProvider projectId="my-project-id" autoRefresh={{ whenActive: true }}>
<App />
</AuthProvider>
```

**Step 2:** Create a component that uses the `useDescope` hook to call `markActive()` on user interactions:

```jsx
import { useEffect } from 'react';
import { useDescope } from '@descope/react-sdk';

function ActivityTracker() {
const sdk = useDescope();

useEffect(() => {
const markActive = () => sdk.markActive();

document.addEventListener('click', markActive, { passive: true, capture: true });
document.addEventListener('keydown', markActive, { passive: true, capture: true });
document.addEventListener('touchstart', markActive, { passive: true, capture: true });

// Mark active when the user switches back to this tab
const onVisibility = () => {
if (document.visibilityState === 'visible') markActive();
};
document.addEventListener('visibilitychange', onVisibility);

return () => {
document.removeEventListener('click', markActive, { capture: true });
document.removeEventListener('keydown', markActive, { capture: true });
document.removeEventListener('touchstart', markActive, { capture: true });
document.removeEventListener('visibilitychange', onVisibility);
};
}, [sdk]);

return null;
}
```

Render `<ActivityTracker />` inside `<AuthProvider>` so `useDescope()` has access to the context:

```jsx
<AuthProvider projectId="my-project-id" autoRefresh={{ whenActive: true }}>
<ActivityTracker />
<App />
</AuthProvider>
```

**For more SDK usage examples refer to [docs](https://docs.descope.com/build/guides/client_sdks/)**

### Session token server validation (pass session token to server API)
Expand Down
30 changes: 27 additions & 3 deletions packages/sdks/react-sdk/examples/app/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';
import React, { useEffect } from 'react';
import { Navigate, Outlet, Route, Routes } from 'react-router-dom';
import { useSession } from '../../src';
import { useDescope, useSession } from '../../src';
import Home from './Home';
import Login from './Login';
import ManageAccessKeys from './ManageAccessKeys';
Expand All @@ -14,6 +14,30 @@ import MyUserProfile from './MyUserProfile';
import OidcLogin from './OidcLogin';
import StepUp from './StepUp';

const ActivityTracker = () => {
const sdk = useDescope();

useEffect(() => {
const markActive = sdk.markActive;

document.addEventListener('click', markActive, {
passive: true,
capture: true,
});
document.addEventListener('keydown', markActive, {
passive: true,
capture: true,
});

return () => {
document.removeEventListener('click', markActive, { capture: true });
document.removeEventListener('keydown', markActive, { capture: true });
};
}, [sdk]);

return null;
};

const Layout = () => (
<div
style={{
Expand All @@ -24,6 +48,7 @@ const Layout = () => (
alignItems: 'center',
}}
>
{process.env.DESCOPE_ACTIVITY_TRACKING === 'true' && <ActivityTracker />}
<div
style={{
borderRadius: 10,
Expand All @@ -43,7 +68,6 @@ const Layout = () => (

const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated, isSessionLoading } = useSession();

if (isSessionLoading) {
return <div>Loading...</div>;
}
Expand Down
5 changes: 5 additions & 0 deletions packages/sdks/react-sdk/examples/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ root.render(
applicationId: process.env.DESCOPE_OIDC_APPLICATION_ID,
}
}
autoRefresh={
process.env.DESCOPE_ACTIVITY_TRACKING === 'true'
? { whenActive: true }
: undefined
}
baseUrl={process.env.DESCOPE_BASE_URL}
baseStaticUrl={process.env.DESCOPE_BASE_STATIC_URL}
baseCdnUrl={process.env.DESCOPE_BASE_CDN_URL}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import React, {
useRef,
useState,
} from 'react';
import { CookieConfig, OidcConfig } from '@descope/web-js-sdk';
import {
CookieConfig,
OidcConfig,
AutoRefreshConfig,
} from '@descope/web-js-sdk';
import { Claims } from '@descope/core-js-sdk';
import { CustomStorage } from '@descope/web-component';
import Context from '../../hooks/Context';
Expand All @@ -25,8 +29,9 @@ interface IAuthProviderProps {
baseCdnUrl?: string;
// Default is true. If true, tokens will be stored on local storage and can accessed with getToken function
persistTokens?: boolean;
// Default is true. If true, the SDK will automatically refresh the session token when it is about to expire
autoRefresh?: boolean;
// Default is true. If true, the SDK will automatically refresh the session token when it is about to expire.
// Pass `{ whenActive: true }` to skip refresh for idle users — you must call `sdk.markActive()` to signal activity.
autoRefresh?: AutoRefreshConfig;
// If true, session token (jwt) will be stored on cookie. Otherwise, the session token will be
// stored on local storage and can accessed with getSessionToken function
// Use this option if session token will stay small (less than 1k)
Expand Down
25 changes: 25 additions & 0 deletions packages/sdks/web-js-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,31 @@ await sdk.oidc.finishLogin();

The SDK will automatically manage the OIDC session for you, according to `persistTokens` and `autoRefresh` options. The SDK will automatically refresh the OIDC session when it expires, and will store the session token in the browser storage or cookies, according to the `persistTokens` option.

### Activity-Based Session Refresh

When `autoRefresh: { whenActive: true }`, the SDK skips refresh calls for idle users. This reduces unnecessary API calls and enables accurate session inactivity tracking on the server. The developer is responsible for calling `sdk.markActive()` to signal user activity.

```js
const sdk = descopeSdk({
projectId: 'xxx',
autoRefresh: { whenActive: true },
});

// Call this whenever the user interacts with your app
document.addEventListener('click', () => sdk.markActive());
document.addEventListener('keydown', () => sdk.markActive());
```

**Behavior:**

- When refresh timer fires and `markActive()` has not been called since the last refresh: skips refresh, marks as skipped
- When `markActive()` is called after a skipped refresh: triggers refresh immediately
- When refresh timer fires and `markActive()` was called: refreshes normally
- After a successful refresh: activity flag is reset (next refresh period starts fresh)
- `sdk.markActive()` is always available — it is a no-op when `whenActive` is not set

This is useful when Descope's session inactivity feature is enabled, ensuring refreshes only occur for genuinely active users.

### Run Example

To run the example:
Expand Down
43 changes: 43 additions & 0 deletions packages/sdks/web-js-sdk/src/enhancers/withAutoRefresh/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,49 @@ import { jwtDecode, JwtPayload } from 'jwt-decode';
import logger from '../helpers/logger';
import { MAX_TIMEOUT, REFRESH_THRESHOLD } from '../../constants';

/**
* Creates a pure state tracker for activity-based session refresh.
*
* State:
* - `hadActivitySinceLastRefresh`: true if `markActive()` was called since the last refresh.
* Starts as true so the first scheduled refresh always proceeds.
* Reset to false by `resetActivity()` after each successful refresh.
* - `refreshWasSkipped`: true if the refresh timer fired but was skipped because the user
* was idle. Cleared when `markActive()` is called or after `resetActivity()`.
*
* Flow:
* - On timer fire: check `hadActivity()`. If false → call `markRefreshSkipped()` and skip.
* - On `markActive()`: set active flag. If a refresh was previously skipped, immediately
* invoke `onActivityAfterSkip` to trigger a catch-up refresh.
* - On successful refresh: call `resetActivity()` to start the next period fresh.
*/
export const createActivityTracker = (onActivityAfterSkip?: () => void) => {
let hadActivitySinceLastRefresh = true; // Start as true (assume active on init)
let refreshWasSkipped = false;

return {
hadActivity: () => hadActivitySinceLastRefresh,
resetActivity: () => {
hadActivitySinceLastRefresh = false;
refreshWasSkipped = false;
},
markRefreshSkipped: () => {
refreshWasSkipped = true;
},
markActive: () => {
const shouldTriggerRefresh = refreshWasSkipped;
hadActivitySinceLastRefresh = true;
if (shouldTriggerRefresh && onActivityAfterSkip) {
logger.debug(
'User became active after skipped refresh, triggering refresh',
);
refreshWasSkipped = false;
onActivityAfterSkip();
}
},
};
};

/**
* Get the JWT expiration WITHOUT VALIDATING the JWT
* @param token The JWT to extract expiration from
Expand Down
71 changes: 57 additions & 14 deletions packages/sdks/web-js-sdk/src/enhancers/withAutoRefresh/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
createTimerFunctions,
getTokenExpiration,
getAutoRefreshTimeout,
createActivityTracker,
} from './helpers';
import { AutoRefreshOptions } from './types';
import logger from '../helpers/logger';
Expand All @@ -23,9 +24,22 @@ import { getRefreshToken } from '../withPersistTokens/helpers';
*/
export const withAutoRefresh =
<T extends CreateWebSdk>(createSdk: T) =>
({ autoRefresh, ...config }: Parameters<T>[0] & AutoRefreshOptions) => {
({
autoRefresh,
...config
}: Parameters<T>[0] & AutoRefreshOptions): ReturnType<T> & {
markActive: () => void;
} => {
const autoRefreshEnabled = !!autoRefresh;
const whenActive =
typeof autoRefresh === 'object' && autoRefresh?.whenActive;

// Never auto refresh in native flows
if (!autoRefresh || isDescopeBridge()) return createSdk(config);
if (!autoRefreshEnabled || isDescopeBridge()) {
return Object.assign(createSdk(config), {
markActive: () => {},
}) as ReturnType<T> & { markActive: () => void };
}

// if we hold a single timer id, there might be a case where we override it before canceling the timer, this might cause many calls to refresh
// in order to prevent it, we hold a list of timers and cancel all of them when a new timer is set, which means we should have one active timer only at a time
Expand All @@ -35,19 +49,32 @@ export const withAutoRefresh =
// when the user comes back to the tab or from background/lock screen/etc.
let sessionExpirationDate: Date;
let refreshToken: string;

let activityTracker: ReturnType<typeof createActivityTracker> | null = null;

if (whenActive) {
logger.debug('Activity-based refresh enabled');
// Callback for when user becomes active after refresh was skipped
const onActivityAfterSkip = () => {
logger.debug('Refreshing session due to user activity after idle skip');
clearAllTimers(); // Prevent race condition with pending timer
sdk.refresh(getRefreshToken() || refreshToken);
};
activityTracker = createActivityTracker(onActivityAfterSkip);
}

if (IS_BROWSER) {
document.addEventListener('visibilitychange', () => {
// tab becomes visible and the session is expired, do a refresh
if (
document.visibilityState === 'visible' &&
sessionExpirationDate &&
new Date() > sessionExpirationDate
) {
logger.debug('Expiration time passed, refreshing session');
// We prefer the persisted refresh token over the one from the response
// for a case that the token was refreshed from another tab, this mostly relevant
// when the project uses token rotation
sdk.refresh(getRefreshToken() || refreshToken);
// tab becomes visible
if (document.visibilityState === 'visible') {
// session is expired, do a refresh
if (sessionExpirationDate && new Date() > sessionExpirationDate) {
logger.debug('Expiration time passed, refreshing session');
// We prefer the persisted refresh token over the one from the response
// for a case that the token was refreshed from another tab, this mostly relevant
// when the project uses token rotation
sdk.refresh(getRefreshToken() || refreshToken);
}
}
});
}
Expand Down Expand Up @@ -95,12 +122,25 @@ export const withAutoRefresh =
`Setting refresh timer for ${refreshTimeStr}. (${timeout}ms)`,
);

// Reset activity tracking after receiving new session (refresh succeeded)
if (activityTracker) {
activityTracker.resetActivity();
}

setTimer(() => {
// Skip refresh if document is hidden - the visibilitychange handler will refresh when user returns
if (IS_BROWSER && document.visibilityState === 'hidden') {
logger.debug('Skipping refresh due to timer - document is hidden');
return;
}

// Check activity if tracking is enabled
if (activityTracker && !activityTracker.hadActivity()) {
logger.debug('Skipping refresh due to timer - user is idle');
activityTracker.markRefreshSkipped();
return; // Don't reschedule - wait for markActive() call
}

logger.debug('Refreshing session due to timer');
// We prefer the persisted refresh token over the one from the response
// for a case that the token was refreshed from another tab, this mostly relevant
Expand All @@ -122,5 +162,8 @@ export const withAutoRefresh =
return resp;
};

return wrapWith(sdk, ['logout', 'logoutAll', 'oidc.logout'], wrapper);
return Object.assign(
wrapWith(sdk, ['logout', 'logoutAll', 'oidc.logout'], wrapper),
{ markActive: activityTracker ? activityTracker.markActive : () => {} },
) as ReturnType<T> & { markActive: () => void };
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
export type AutoRefreshConfig = boolean | { whenActive?: boolean };

export type AutoRefreshOptions = {
// If true, sdk object will trigger refresh on init, and in intervals manner, in order to to retain a valid session token
autoRefresh?: boolean;
// If an object with `whenActive: true`, refresh is skipped for idle users until `sdk.markActive()` is called
autoRefresh?: AutoRefreshConfig;
};
6 changes: 4 additions & 2 deletions packages/sdks/web-js-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ import createSdk from './sdk';
const decoratedCreateSdk = compose(
withCustomStorage, // must be first
withFingerprint,
withAutoRefresh,
withAnalytics,
withNotifications,
withFlowNonce,
withLastLoggedInUser, // must be one before last due to TS types
// The following two enhancers must remain immediately before withPersistTokens due to TS type inference limitations
withAutoRefresh,
withLastLoggedInUser,
withPersistTokens, // must be last due to TS known limitation https://github.com/microsoft/TypeScript/issues/30727
)(createSdk);

Expand All @@ -39,5 +40,6 @@ export type { JWTResponse } from '@descope/core-js-sdk';
export type { OneTapConfig } from './sdk/fedcm';
export type { CookieConfig } from './enhancers/withPersistTokens/types';
export type { FlowNonceOptions } from './enhancers/withFlowNonce/types';
export type { AutoRefreshConfig } from './enhancers/withAutoRefresh/types';
export default decoratedCreateSdk;
export { decoratedCreateSdk as createSdk };
Loading
Loading