Skip to content

Commit 2cd530b

Browse files
feat: enforce IPSIE session_expiry ceiling on local session lifetime (#1126)
## Summary - Bumps `@auth0/auth0-spa-js` to `^2.22.0` to pick up IPSIE `session_expiry` enforcement - Adds one unit test covering mid-session ceiling breach via `getAccessTokenSilently` - Adds `Session Expiry from Upstream IdP (IPSIE)` section to `EXAMPLES.md` No provider code changes are needed. Enforcement is fully handled by `auth0-spa-js`. The React layer already propagates the `undefined` user/token responses into `isAuthenticated: false` state transitions. Routes wrapped with `withAuthenticationRequired` require no code changes; components calling `getAccessTokenSilently()` imperatively need an explicit null check (documented in EXAMPLES.md). ## Test plan - [x] All existing unit tests pass - [x] New `session_expiry ceiling (IPSIE SL1)` test passes - [x] Manual: deploy a Post-Login Action with `api.idToken.setCustomClaim('session_expiry', Math.floor(Date.now() / 1000) + 120)`, log in, wait 2 minutes. The app should redirect to login without any code changes. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Documentation** * Added a new guide section covering upstream IdP session expiry, including claim setup and a walkthrough of resulting SDK behavior. * Documented how protected routes behave when authentication/session limits are reached, plus upgrade guidance for apps that assume tokens/users are always present. * **Bug Fixes** * Improved handling when silent token retrieval resolves to no value, ensuring auth state is cleared and `getAccessTokenSilently()` returns `undefined`. * **Tests** * Added coverage to verify the new silent-token/clearing behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 76cc155 commit 2cd530b

3 files changed

Lines changed: 106 additions & 1 deletion

File tree

EXAMPLES.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
- [Native to Web SSO](#native-to-web-sso)
1919
- [Passkeys](#passkeys)
2020
- [MyAccount API](#myaccount-api)
21+
- [Session Expiry from Upstream IdP (IPSIE)](#session-expiry-from-upstream-idp-ipsie)
2122

2223
## Use with a Class Component
2324

@@ -1616,3 +1617,83 @@ try {
16161617
}
16171618
}
16181619
```
1620+
1621+
## Session Expiry from Upstream IdP (IPSIE)
1622+
1623+
When using an Okta or OIDC enterprise connection configured with `id_token_session_expiry_supported: true`, Auth0 includes a `session_expiry` claim in the ID token. This is an absolute Unix timestamp (seconds) that acts as a hard ceiling on the local session — the SDK will not return tokens or a user once this point in time is reached.
1624+
1625+
You can also emit the claim from a Post-Login Action:
1626+
1627+
```js
1628+
exports.onExecutePostLogin = async (event, api) => {
1629+
// Value must be Unix seconds, not milliseconds.
1630+
api.idToken.setCustomClaim('session_expiry', Math.floor(Date.now() / 1000) + 7200); // 2-hour ceiling
1631+
};
1632+
```
1633+
1634+
### Behavior
1635+
1636+
When the ceiling is reached, `useAuth0()` reflects the expired state on the next call to `getAccessTokenSilently`, `getUser`, or `getIdTokenClaims` — there is no background timer or automatic re-check:
1637+
1638+
- `isAuthenticated` becomes `false`
1639+
- `user` becomes `undefined`
1640+
- `getAccessTokenSilently()` returns `undefined` (no error thrown)
1641+
1642+
If your routes are wrapped with `withAuthenticationRequired`, no code changes are required — the next time a component calls `getAccessTokenSilently` or `getUser`, the state updates and the HOC redirects to login. A user sitting on a page that makes no token or user calls will remain authenticated in the React state until the next such call.
1643+
1644+
```jsx
1645+
// When a token or user call occurs after the ceiling, isAuthenticated becomes false
1646+
// and the HOC redirects to login.
1647+
export default withAuthenticationRequired(Dashboard);
1648+
```
1649+
1650+
### Reading the claim
1651+
1652+
`session_expiry` is a standard ID token claim and is available via `getIdTokenClaims()`. Note that `getIdTokenClaims()` returns `undefined` once the ceiling is reached — this is useful for displaying time remaining before expiry, not for detecting expiry itself.
1653+
1654+
```jsx
1655+
import { useEffect } from 'react';
1656+
import { useAuth0 } from '@auth0/auth0-react';
1657+
1658+
function SessionInfo() {
1659+
const { getIdTokenClaims } = useAuth0();
1660+
1661+
useEffect(() => {
1662+
getIdTokenClaims().then((claims) => {
1663+
if (claims?.session_expiry) {
1664+
const ceiling = new Date(claims.session_expiry * 1000);
1665+
console.log('Session ceiling:', ceiling.toISOString());
1666+
}
1667+
});
1668+
}, [getIdTokenClaims]);
1669+
1670+
return null;
1671+
}
1672+
```
1673+
1674+
### Upgrading existing apps
1675+
1676+
Once the feature is enabled, `user` and `getAccessTokenSilently()` can return `undefined` for a previously authenticated user when the ceiling is reached. Apps that assume these are always set after login should add null checks:
1677+
1678+
```jsx
1679+
function CallApi() {
1680+
const { getAccessTokenSilently } = useAuth0();
1681+
1682+
async function fetchData() {
1683+
const token = await getAccessTokenSilently();
1684+
1685+
if (!token) {
1686+
// Ceiling was reached — return here and let the re-render cycle handle
1687+
// the redirect via withAuthenticationRequired or your route guard.
1688+
// Calling loginWithRedirect() directly risks a double redirect if a HOC is present.
1689+
return;
1690+
}
1691+
1692+
await fetch('/api/data', { headers: { Authorization: `Bearer ${token}` } });
1693+
}
1694+
1695+
return <button onClick={fetchData}>Fetch</button>;
1696+
}
1697+
```
1698+
1699+
Using `withAuthenticationRequired` on protected routes is the simpler alternative — the redirect happens automatically without the null check.

__tests__/auth-provider.test.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1506,4 +1506,28 @@ describe('Auth0Provider', () => {
15061506
});
15071507
});
15081508
});
1509+
1510+
describe('when getTokenSilently returns undefined', () => {
1511+
it('should call getTokenSilently, return undefined, and clear auth state', async () => {
1512+
clientMock.getUser.mockResolvedValue({ sub: '__test_user__', name: 'Test User' });
1513+
const wrapper = createWrapper();
1514+
const { result } = renderHook(() => useAuth0(), { wrapper });
1515+
1516+
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
1517+
1518+
(clientMock.getTokenSilently as jest.Mock).mockResolvedValue(undefined);
1519+
clientMock.getUser.mockResolvedValue(undefined);
1520+
1521+
let token: string | undefined;
1522+
await act(async () => {
1523+
token = await result.current.getAccessTokenSilently();
1524+
});
1525+
1526+
expect(clientMock.getTokenSilently).toHaveBeenCalled();
1527+
expect(token).toBeUndefined();
1528+
expect(result.current.isAuthenticated).toBe(false);
1529+
expect(result.current.user).toBeUndefined();
1530+
expect(result.current.error).toBeUndefined();
1531+
});
1532+
});
15091533
});

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,6 @@
9393
"react-dom": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1"
9494
},
9595
"dependencies": {
96-
"@auth0/auth0-spa-js": "^2.21.2"
96+
"@auth0/auth0-spa-js": "^2.22.0"
9797
}
9898
}

0 commit comments

Comments
 (0)