diff --git a/.changeset/nextjs-client-token-refresh.md b/.changeset/nextjs-client-token-refresh.md new file mode 100644 index 000000000..d37e1a539 --- /dev/null +++ b/.changeset/nextjs-client-token-refresh.md @@ -0,0 +1,5 @@ +--- +'@asgardeo/nextjs': patch +--- + +The session stays alive while a page is open. Tokens were only refreshed by the middleware on navigation, so a long-lived page, or an app set up without the middleware, silently lost its session once the access token expired even though the refresh token was still valid; the `refreshToken` server action existed but nothing called it. `AsgardeoProvider` now passes the session expiry to the client, which refreshes the token shortly before it expires and schedules the next refresh from the result, as the React SDK does. The scheduled refresh asks the action to refresh only when the token is actually about to expire, so it does not compete with a refresh the middleware has just done. diff --git a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx index de77065f8..3a6128f7a 100644 --- a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx +++ b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx @@ -51,11 +51,18 @@ import {AppRouterInstance} from 'next/dist/shared/lib/app-router-context.shared- import {useRouter, useSearchParams} from 'next/navigation'; import {FC, PropsWithChildren, RefObject, useEffect, useMemo, useRef, useState} from 'react'; import AsgardeoContext, {AsgardeoContextProps} from './AsgardeoContext'; +import {REFRESH_BUFFER_SECONDS} from '../../../constants/sessionConstants'; import {HttpRequestActionResult} from '../../../server/actions/httpRequestAction'; -import {RefreshResult} from '../../../server/actions/refreshToken'; +import {RefreshResult, RefreshTokenOptions} from '../../../server/actions/refreshToken'; import logger from '../../../utils/logger'; import navigateTo from '../../../utils/navigateTo'; +/** + * Lower bound for the delay of a scheduled refresh, so that a token that is already expired (for example + * after the tab was suspended) is refreshed right away without hammering the server in a tight loop. + */ +const MIN_REFRESH_DELAY_MS: number = 5_000; + /** * Props interface of {@link AsgardeoClientProvider} */ @@ -77,8 +84,13 @@ export type AsgardeoClientProviderProps = Partial Promise; + refreshToken: (options?: RefreshTokenOptions) => Promise; revalidateMyOrganizations?: (sessionId?: string) => Promise; + /** + * Expiry of the current access token (epoch seconds), taken from the session cookie on the server. + * The provider schedules a refresh shortly before it so the session stays alive while the page is open. + */ + sessionExpiresAt?: number; signIn: AsgardeoContextProps['signIn']; signOut: AsgardeoContextProps['signOut']; signUp: AsgardeoContextProps['signUp']; @@ -118,6 +130,7 @@ const AsgardeoClientProvider: FC> brandingPreference, afterSignInUrl, httpRequest, + sessionExpiresAt, }: PropsWithChildren) => { const reRenderCheckRef: RefObject = useRef(false); const router: AppRouterInstance = useRouter(); @@ -205,6 +218,53 @@ const AsgardeoClientProvider: FC> setIsLoading(false); }, [isSignedIn, user]); + // Keep the session alive while the page stays open, as the React SDK does: refresh the access token + // shortly before it expires and schedule the next refresh from the result. The middleware refreshes on + // navigation only, so without this a long-lived page (or an app without the middleware) silently lost + // its session once the access token expired although the refresh token was still valid. + useEffect(() => { + if (!isSignedIn || !sessionExpiresAt || !refreshToken) { + return undefined; + } + + let timer: ReturnType | undefined; + let cancelled: boolean = false; + + const schedule = (expiresAt: number): void => { + const delay: number = Math.max((expiresAt - REFRESH_BUFFER_SECONDS) * 1000 - Date.now(), MIN_REFRESH_DELAY_MS); + + timer = setTimeout(async (): Promise => { + try { + // `onlyIfExpiring` makes this a no-op when the middleware already refreshed the session. + const result: RefreshResult = await refreshToken({onlyIfExpiring: true}); + + if (!cancelled) { + schedule(result.expiresAt); + } + } catch (error) { + logger.warn( + '[AsgardeoClientProvider] Could not refresh the session; re-rendering the server components so the signed-out state is picked up.', + error, + ); + + if (!cancelled) { + router.refresh(); + } + } + }, delay); + }; + + schedule(sessionExpiresAt); + + return (): void => { + cancelled = true; + + if (timer) { + clearTimeout(timer); + } + }; + }, [isSignedIn, sessionExpiresAt]); + const handleSignIn = async ( payload: EmbeddedSignInFlowHandleRequestPayload, request: EmbeddedFlowExecuteRequestConfig, diff --git a/packages/nextjs/src/server/AsgardeoProvider.tsx b/packages/nextjs/src/server/AsgardeoProvider.tsx index 5015697ac..d22a1202d 100644 --- a/packages/nextjs/src/server/AsgardeoProvider.tsx +++ b/packages/nextjs/src/server/AsgardeoProvider.tsx @@ -234,6 +234,7 @@ const AsgardeoServerProvider: FC> userProfile={userProfile} updateProfile={updateUserProfileAction} isSignedIn={signedIn} + sessionExpiresAt={sessionPayload?.exp} myOrganizations={myOrganizations} getAllOrganizations={getAllOrganizations} switchOrganization={switchOrganization} diff --git a/packages/nextjs/src/server/actions/__tests__/refreshToken.test.ts b/packages/nextjs/src/server/actions/__tests__/refreshToken.test.ts new file mode 100644 index 000000000..6cb092ad6 --- /dev/null +++ b/packages/nextjs/src/server/actions/__tests__/refreshToken.test.ts @@ -0,0 +1,129 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {cookies} from 'next/headers'; +import {afterEach, beforeEach, describe, expect, it, vi, Mock} from 'vitest'; +import AsgardeoNextClient from '../../../AsgardeoNextClient'; +import handleRefreshToken from '../../../utils/handleRefreshToken'; +import SessionManager from '../../../utils/SessionManager'; +import refreshToken, {RefreshResult} from '../refreshToken'; + +vi.mock('next/headers', () => ({ + cookies: vi.fn(), +})); + +vi.mock('../../../AsgardeoNextClient', () => ({ + default: { + getInstance: vi.fn(), + }, +})); + +vi.mock('../../../utils/handleRefreshToken', () => ({ + default: vi.fn(), +})); + +vi.mock('../../../utils/SessionManager', () => ({ + default: { + getSessionCookieName: vi.fn(() => 'session'), + getSessionCookieOptions: vi.fn((maxAge: number) => ({httpOnly: true, maxAge})), + verifySessionTokenForRefresh: vi.fn(), + }, +})); + +describe('refreshToken', () => { + const NOW: number = 1_800_000_000_000; + const nowSeconds: number = NOW / 1000; + const cookieStore: {delete: Mock; get: Mock; set: Mock} = {delete: vi.fn(), get: vi.fn(), set: vi.fn()}; + const client: {getConfiguration: Mock} = {getConfiguration: vi.fn()}; + + const sessionExpiringIn = (seconds: number): Record => ({ + exp: nowSeconds + seconds, + refreshToken: 'refresh-1', + sessionId: 'session-1', + sub: 'user-1', + type: 'session', + }); + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(NOW); + + (cookies as unknown as Mock).mockResolvedValue(cookieStore); + cookieStore.get.mockReturnValue({value: 'session.jwt'}); + (AsgardeoNextClient.getInstance as unknown as Mock).mockReturnValue(client); + client.getConfiguration.mockResolvedValue({ + baseUrl: 'https://api.asgardeo.io/t/acme', + clientId: 'client-id', + clientSecret: 'client-secret', + }); + (handleRefreshToken as unknown as Mock).mockResolvedValue({ + newSessionToken: 'new.session.jwt', + sessionCookieExpiryTime: 86400, + tokenResponse: {expiresIn: '3600'}, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('exchanges the refresh token, stores the new session cookie and returns the new expiry', async () => { + (SessionManager.verifySessionTokenForRefresh as unknown as Mock).mockResolvedValue(sessionExpiringIn(10)); + + const result: RefreshResult = await refreshToken(); + + expect(handleRefreshToken).toHaveBeenCalledTimes(1); + expect(cookieStore.set).toHaveBeenCalledWith('session', 'new.session.jwt', {httpOnly: true, maxAge: 86400}); + expect(result).toEqual({expiresAt: nowSeconds + 3600}); + }); + + it('skips the exchange when only an expiring session should be refreshed and the token is still fresh', async () => { + (SessionManager.verifySessionTokenForRefresh as unknown as Mock).mockResolvedValue(sessionExpiringIn(1800)); + + const result: RefreshResult = await refreshToken({onlyIfExpiring: true}); + + expect(handleRefreshToken).not.toHaveBeenCalled(); + expect(cookieStore.set).not.toHaveBeenCalled(); + expect(result).toEqual({expiresAt: nowSeconds + 1800}); + }); + + it('refreshes an expiring session when only an expiring session should be refreshed', async () => { + (SessionManager.verifySessionTokenForRefresh as unknown as Mock).mockResolvedValue(sessionExpiringIn(10)); + + const result: RefreshResult = await refreshToken({onlyIfExpiring: true}); + + expect(handleRefreshToken).toHaveBeenCalledTimes(1); + expect(result).toEqual({expiresAt: nowSeconds + 3600}); + }); + + it('clears the session cookie and rejects when the refresh fails', async () => { + (SessionManager.verifySessionTokenForRefresh as unknown as Mock).mockResolvedValue(sessionExpiringIn(10)); + (handleRefreshToken as unknown as Mock).mockRejectedValue(new Error('Token endpoint rejected refresh (HTTP 400).')); + + await expect(refreshToken()).rejects.toThrow(/HTTP 400/); + expect(cookieStore.delete).toHaveBeenCalledWith('session'); + }); + + it('rejects when there is no session cookie', async () => { + cookieStore.get.mockReturnValue(undefined); + + await expect(refreshToken()).rejects.toThrow(/No active session/); + expect(handleRefreshToken).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nextjs/src/server/actions/refreshToken.ts b/packages/nextjs/src/server/actions/refreshToken.ts index a8dd65df4..2c195e9b5 100644 --- a/packages/nextjs/src/server/actions/refreshToken.ts +++ b/packages/nextjs/src/server/actions/refreshToken.ts @@ -21,6 +21,7 @@ import {AsgardeoAPIError, logger} from '@asgardeo/node'; import {cookies} from 'next/headers'; import AsgardeoNextClient from '../../AsgardeoNextClient'; +import {REFRESH_BUFFER_SECONDS} from '../../constants/sessionConstants'; import {AsgardeoNextConfig} from '../../models/config'; import handleRefreshToken, {HandleRefreshTokenResult} from '../../utils/handleRefreshToken'; import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager'; @@ -42,6 +43,18 @@ export interface RefreshResult { expiresAt: number; } +/** + * Options of {@link refreshToken}. + */ +export interface RefreshTokenOptions { + /** + * Only exchange the refresh token when the access token expires within the refresh buffer. + * Used by the scheduled refresh in the client provider: when the middleware has already refreshed the + * session for a recent request, the current expiry is returned without a second token request. + */ + onlyIfExpiring?: boolean; +} + /** * Server action to refresh the access token using the stored refresh token. * Exchanges the refresh token for a new token set and updates the session cookie. @@ -49,11 +62,11 @@ export interface RefreshResult { * Delegates the HTTP exchange to handleRefreshToken so the same logic is shared * with the middleware token refresh path. * - * Called from the client side (e.g. AsgardeoClientProvider refreshOnMount) where - * Next.js allows cookie mutation. When invoked during SSR rendering the cookie - * write is silently skipped and a warning is logged. + * Called from the client side (the client provider schedules it shortly before the access token expires, + * and `useAsgardeo().refreshToken()` exposes it) where Next.js allows cookie mutation. When invoked during + * SSR rendering the cookie write is silently skipped and a warning is logged. */ -const refreshToken = async (): Promise => { +const refreshToken = async (options?: RefreshTokenOptions): Promise => { try { const cookieStore: RequestCookies = await cookies(); const sessionToken: string | undefined = cookieStore.get(SessionManager.getSessionCookieName())?.value; @@ -68,6 +81,14 @@ const refreshToken = async (): Promise => { } const sessionPayload: SessionTokenPayload = await SessionManager.verifySessionTokenForRefresh(sessionToken); + const now: number = Math.floor(Date.now() / 1000); + + if (options?.onlyIfExpiring && sessionPayload.exp > now + REFRESH_BUFFER_SECONDS) { + logger.debug('[refreshToken] The session is not close to expiry; skipping the refresh.'); + + return {expiresAt: sessionPayload.exp}; + } + const client: AsgardeoNextClient = AsgardeoNextClient.getInstance(); const config: AsgardeoNextConfig = await client.getConfiguration();