Skip to content

Commit 63added

Browse files
feat: enforce IPSIE session_expiry ceiling on local session lifetime
1 parent c899362 commit 63added

3 files changed

Lines changed: 125 additions & 1 deletion

File tree

EXAMPLES.md

Lines changed: 79 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,81 @@ 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+
Enforcement is transparent — no code changes are required. When the ceiling is reached, `useAuth0()` reflects the expired state on the next render:
1637+
1638+
- `isAuthenticated` becomes `false`
1639+
- `user` becomes `undefined`
1640+
- `getAccessTokenSilently()` returns `undefined` (no error thrown)
1641+
1642+
Because this is identical to "no session", any route guard or `withAuthenticationRequired` wrapper already in your app will trigger re-authentication automatically.
1643+
1644+
```jsx
1645+
// This component already handles the session_expiry ceiling with no changes.
1646+
// When the ceiling passes, isAuthenticated becomes false 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()`:
1653+
1654+
```jsx
1655+
import { useAuth0 } from '@auth0/auth0-react';
1656+
1657+
function SessionInfo() {
1658+
const { getIdTokenClaims } = useAuth0();
1659+
1660+
useEffect(() => {
1661+
getIdTokenClaims().then((claims) => {
1662+
if (claims?.session_expiry) {
1663+
const ceiling = new Date(claims.session_expiry * 1000);
1664+
console.log('Session ceiling:', ceiling.toISOString());
1665+
}
1666+
});
1667+
}, [getIdTokenClaims]);
1668+
1669+
return null;
1670+
}
1671+
```
1672+
1673+
### Upgrading existing apps
1674+
1675+
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:
1676+
1677+
```jsx
1678+
function CallApi() {
1679+
const { getAccessTokenSilently, loginWithRedirect } = useAuth0();
1680+
1681+
async function fetchData() {
1682+
const token = await getAccessTokenSilently();
1683+
1684+
if (!token) {
1685+
// Ceiling was reached — redirect to login.
1686+
await loginWithRedirect();
1687+
return;
1688+
}
1689+
1690+
await fetch('/api/data', { headers: { Authorization: `Bearer ${token}` } });
1691+
}
1692+
1693+
return <button onClick={fetchData}>Fetch</button>;
1694+
}
1695+
```
1696+
1697+
Using `withAuthenticationRequired` on protected routes is the simpler alternative — the redirect happens automatically without the null check.

__tests__/auth-provider.test.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1506,4 +1506,49 @@ describe('Auth0Provider', () => {
15061506
});
15071507
});
15081508
});
1509+
1510+
describe('session_expiry ceiling (IPSIE SL1)', () => {
1511+
it('should be unauthenticated on init when session ceiling was breached', async () => {
1512+
// When the session_expiry ceiling is reached, auth0-spa-js tears down the local
1513+
// session inside checkSession and getUser returns undefined. The React layer
1514+
// should surface this as isAuthenticated: false with no error, so the existing
1515+
// redirect-to-login path fires transparently (Req 5).
1516+
clientMock.getUser.mockResolvedValue(undefined);
1517+
const wrapper = createWrapper();
1518+
const { result } = renderHook(() => useAuth0(), { wrapper });
1519+
1520+
await waitFor(() => expect(result.current.isLoading).toBe(false));
1521+
1522+
expect(result.current.isAuthenticated).toBe(false);
1523+
expect(result.current.user).toBeUndefined();
1524+
expect(result.current.error).toBeUndefined();
1525+
});
1526+
1527+
it('should return undefined and clear auth state when session ceiling is breached during getAccessTokenSilently', async () => {
1528+
// Start with an authenticated session.
1529+
clientMock.getUser.mockResolvedValue({ sub: '__test_user__', name: 'Test User' });
1530+
const wrapper = createWrapper();
1531+
const { result } = renderHook(() => useAuth0(), { wrapper });
1532+
1533+
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
1534+
1535+
// auth0-spa-js signals a ceiling breach by returning undefined from
1536+
// getTokenSilently (no throw) and undefined from getUser (session cleared).
1537+
// The React layer must propagate this as a silent state transition to
1538+
// unauthenticated — not an error — so withAuthenticationRequired / route
1539+
// guards fire on their own without developer try/catch (Req 5).
1540+
(clientMock.getTokenSilently as jest.Mock).mockResolvedValue(undefined);
1541+
clientMock.getUser.mockResolvedValue(undefined);
1542+
1543+
let token: string | undefined;
1544+
await act(async () => {
1545+
token = await result.current.getAccessTokenSilently();
1546+
});
1547+
1548+
expect(token).toBeUndefined();
1549+
expect(result.current.isAuthenticated).toBe(false);
1550+
expect(result.current.user).toBeUndefined();
1551+
expect(result.current.error).toBeUndefined();
1552+
});
1553+
});
15091554
});

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)