From d6b5cbac5fcf15b4e9af0f0a9b12e0180b573bed Mon Sep 17 00:00:00 2001 From: kavindadewmith Date: Sun, 19 Apr 2026 14:09:09 +0530 Subject: [PATCH 01/12] Implement token refresh and session management enhancements - Introduced `refreshToken` action to refresh access tokens using stored refresh tokens. - Updated `isSignedIn` action to streamline session validation logic. - Enhanced `signInAction` and `switchOrganization` actions to handle refresh tokens and session expiry. - Modified `handleOAuthCallbackAction` to include refresh token handling. - Improved `AsgardeoProvider` to support new actions and session management features. - Added constants for default access token and session expiry durations. - Created `tokenRefreshCore` utility for handling token refresh logic. - Updated middleware to proactively refresh tokens and manage session state effectively. - Added `clearSession` action to delete session cookies without contacting the identity server. --- packages/javascript/esbuild.config.mjs | 14 + packages/javascript/package.json | 1 + packages/nextjs/esbuild.config.mjs | 2 +- packages/nextjs/package.json | 6 + .../contexts/Asgardeo/AsgardeoContext.ts | 8 +- .../contexts/Asgardeo/AsgardeoProvider.tsx | 6 + packages/nextjs/src/middleware.ts | 37 +++ packages/nextjs/src/models/config.ts | 17 +- .../nextjs/src/server/AsgardeoProvider.tsx | 6 +- .../nextjs/src/server/actions/clearSession.ts | 53 +++ .../actions/handleOAuthCallbackAction.ts | 11 +- .../nextjs/src/server/actions/isSignedIn.ts | 27 +- .../nextjs/src/server/actions/refreshToken.ts | 102 ++++++ .../nextjs/src/server/actions/signInAction.ts | 21 +- .../src/server/actions/switchOrganization.ts | 28 +- packages/nextjs/src/server/index.ts | 4 - .../server/middleware/asgardeoMiddleware.ts | 312 +++++++++++++----- packages/nextjs/src/utils/SessionManager.ts | 53 ++- packages/nextjs/src/utils/constants.ts | 47 +++ packages/nextjs/src/utils/tokenRefreshCore.ts | 131 ++++++++ 20 files changed, 744 insertions(+), 142 deletions(-) create mode 100644 packages/nextjs/src/middleware.ts create mode 100644 packages/nextjs/src/server/actions/clearSession.ts create mode 100644 packages/nextjs/src/server/actions/refreshToken.ts create mode 100644 packages/nextjs/src/utils/constants.ts create mode 100644 packages/nextjs/src/utils/tokenRefreshCore.ts diff --git a/packages/javascript/esbuild.config.mjs b/packages/javascript/esbuild.config.mjs index 75bd11607..77091aa78 100644 --- a/packages/javascript/esbuild.config.mjs +++ b/packages/javascript/esbuild.config.mjs @@ -42,3 +42,17 @@ await esbuild.build({ outfile: 'dist/cjs/index.js', sourcemap: true, }); + +await esbuild.build({ + ...commonOptions, + format: 'esm', + outfile: 'dist/edge/index.js', + platform: 'browser', + define: { + // process.versions is a Node.js-only API not available in the Edge Runtime. + // Replacing it with undefined makes isNode() evaluate to false, causing the + // logger to fall back to plain console.log() — the correct behaviour for Edge. + 'process.versions': 'undefined', + }, + sourcemap: true, +}); diff --git a/packages/javascript/package.json b/packages/javascript/package.json index 67e0f9618..f8e47d0c8 100644 --- a/packages/javascript/package.json +++ b/packages/javascript/package.json @@ -19,6 +19,7 @@ "main": "dist/cjs/index.js", "module": "dist/index.js", "exports": { + "edge-light": "./dist/edge/index.js", "import": "./dist/index.js", "require": "./dist/cjs/index.js" }, diff --git a/packages/nextjs/esbuild.config.mjs b/packages/nextjs/esbuild.config.mjs index b696504e7..ecd8e433d 100644 --- a/packages/nextjs/esbuild.config.mjs +++ b/packages/nextjs/esbuild.config.mjs @@ -20,7 +20,7 @@ import {build} from 'esbuild'; const commonOptions = { bundle: false, - entryPoints: ['src/index.ts', 'src/server/index.ts'], + entryPoints: ['src/index.ts', 'src/server/index.ts', 'src/middleware.ts'], platform: 'node', target: ['node18'], }; diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index a5a117e1d..713e5ccd5 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -28,6 +28,12 @@ "types": "./dist/types/server/index.d.ts", "import": "./dist/esm/server/index.js", "require": "./dist/cjs/server/index.js" + }, + "./middleware": { + "edge-light": "./dist/esm/middleware.js", + "types": "./dist/types/middleware.d.ts", + "import": "./dist/esm/middleware.js", + "require": "./dist/cjs/middleware.js" } }, "files": [ diff --git a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts index e42d6408c..4eae24e0f 100644 --- a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts +++ b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts @@ -18,13 +18,17 @@ 'use client'; +import {TokenResponse} from '@asgardeo/node'; import {AsgardeoContextProps as AsgardeoReactContextProps} from '@asgardeo/react'; import {Context, createContext} from 'react'; /** * Props interface of {@link AsgardeoContext} */ -export type AsgardeoContextProps = Partial; +export type AsgardeoContextProps = Partial & { + clearSession?: () => Promise; + refreshToken?: () => Promise; +}; /** * Context object for managing the Authentication flow builder core context. @@ -33,10 +37,12 @@ const AsgardeoContext: Context = createContext Promise.resolve(), isInitialized: false, isLoading: true, isSignedIn: false, organizationHandle: undefined, + refreshToken: () => Promise.resolve({} as TokenResponse), signIn: () => Promise.resolve({} as any), signInUrl: undefined, signOut: () => Promise.resolve({} as any), diff --git a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx index 475192363..2f8463469 100644 --- a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx +++ b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx @@ -65,11 +65,13 @@ export type AsgardeoClientProviderProps = Partial Promise<{error?: string; redirectUrl?: string; success: boolean}>; + clearSession: () => Promise; isSignedIn: boolean; myOrganizations: Organization[]; organizationHandle: AsgardeoContextProps['organizationHandle']; revalidateMyOrganizations?: (sessionId?: string) => Promise; signIn: AsgardeoContextProps['signIn']; + refreshToken: () => Promise; signOut: AsgardeoContextProps['signOut']; signUp: AsgardeoContextProps['signUp']; switchOrganization: (organization: Organization, sessionId?: string) => Promise; @@ -85,6 +87,8 @@ const AsgardeoClientProvider: FC> baseUrl, children, signIn, + clearSession, + refreshToken, signOut, signUp, handleOAuthCallback, @@ -292,10 +296,12 @@ const AsgardeoClientProvider: FC> () => ({ applicationId, baseUrl, + clearSession, isLoading, isSignedIn, organizationHandle, signIn: handleSignIn, + refreshToken, signInUrl, signOut: handleSignOut, signUp: handleSignUp, diff --git a/packages/nextjs/src/middleware.ts b/packages/nextjs/src/middleware.ts new file mode 100644 index 000000000..516d24076 --- /dev/null +++ b/packages/nextjs/src/middleware.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2025, 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. + */ + +/** + * Edge Runtime entry point — safe for use in Next.js middleware.ts. + * + * This file must only import modules whose full transitive dependency graph + * contains zero Node.js-only APIs (process.versions, fs, crypto, etc.). + * Permitted dependencies: jose, fetch, next/server, and local utilities + * that themselves satisfy the same constraint. + * + * Do NOT import from: + * - AsgardeoNextClient (depends on @asgardeo/node → @asgardeo/javascript) + * - server/AsgardeoProvider (depends on @asgardeo/node) + * - server/actions/* (depend on @asgardeo/node) + * - client/* (depend on @asgardeo/javascript via @asgardeo/react) + */ + +export {default as asgardeoMiddleware} from './server/middleware/asgardeoMiddleware'; +export * from './server/middleware/asgardeoMiddleware'; + +export {default as createRouteMatcher} from './server/middleware/createRouteMatcher'; diff --git a/packages/nextjs/src/models/config.ts b/packages/nextjs/src/models/config.ts index b36602f8b..617e29c59 100644 --- a/packages/nextjs/src/models/config.ts +++ b/packages/nextjs/src/models/config.ts @@ -29,4 +29,19 @@ import {AsgardeoNodeConfig} from '@asgardeo/node'; * - Session configuration for Next.js apps * - Environment variable integration */ -export type AsgardeoNextConfig = AsgardeoNodeConfig; +export type AsgardeoNextConfig = AsgardeoNodeConfig & { + /** + * Session lifetime in seconds. Determines how long the session JWT and its + * corresponding cookie remain valid after sign-in. + * + * Resolution order (first defined value wins): + * 1. This field — set programmatically at SDK initialisation. + * 2. `ASGARDEO_SESSION_EXPIRY_SECONDS` environment variable. + * 3. Built-in default of 86400 seconds (24 hours). + * + * @example + * // 8-hour session + * { sessionExpirySeconds: 28800 } + */ + sessionExpirySeconds?: number; +}; diff --git a/packages/nextjs/src/server/AsgardeoProvider.tsx b/packages/nextjs/src/server/AsgardeoProvider.tsx index e6b0ad085..b5d833d05 100644 --- a/packages/nextjs/src/server/AsgardeoProvider.tsx +++ b/packages/nextjs/src/server/AsgardeoProvider.tsx @@ -33,6 +33,8 @@ import getUserProfileAction from './actions/getUserProfileAction'; import handleOAuthCallbackAction from './actions/handleOAuthCallbackAction'; import isSignedIn from './actions/isSignedIn'; import signInAction from './actions/signInAction'; +import clearSession from './actions/clearSession'; +import refreshToken from './actions/refreshToken'; import signOutAction from './actions/signOutAction'; import signUpAction from './actions/signUpAction'; import switchOrganization from './actions/switchOrganization'; @@ -99,7 +101,7 @@ const AsgardeoServerProvider: FC> // Try to get session information from JWT first, then fall back to legacy const sessionPayload: SessionTokenPayload | undefined = await getSessionPayload(); const sessionId: string = sessionPayload?.sessionId || (await getSessionId()) || ''; - const signedIn: boolean = sessionPayload ? true : await isSignedIn(sessionId); + const signedIn: boolean = await isSignedIn(sessionId); let user: User = {}; let userProfile: UserProfile = { @@ -203,6 +205,8 @@ const AsgardeoServerProvider: FC> applicationId={config?.applicationId} baseUrl={config?.baseUrl} signIn={signInAction} + clearSession={clearSession} + refreshToken={refreshToken} signOut={signOutAction} signUp={signUpAction} handleOAuthCallback={handleOAuthCallbackAction} diff --git a/packages/nextjs/src/server/actions/clearSession.ts b/packages/nextjs/src/server/actions/clearSession.ts new file mode 100644 index 000000000..8c9d6563d --- /dev/null +++ b/packages/nextjs/src/server/actions/clearSession.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2025, 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. + */ + +'use server'; + +import {ReadonlyRequestCookies} from 'next/dist/server/web/spec-extension/adapters/request-cookies'; +import {cookies} from 'next/headers'; +import SessionManager from '../../utils/SessionManager'; +import logger from '../../utils/logger'; + +/** + * Deletes all Asgardeo session cookies from the browser without contacting the + * identity server. + * + * Use this for error-recovery scenarios where the local session must be wiped + * immediately: refresh token failures, corrupt sessions, or forced local sign-out + * when the identity server is unreachable. + * + * For a complete sign-out that also revokes the server-side session and obtains the + * after-sign-out redirect URL, use `signOutAction` instead. + * + * @example + * ```typescript + * import { clearSession } from '@asgardeo/nextjs/server'; + * + * // Inside a Server Action or Route Handler: + * await clearSession(); + * redirect('/sign-in'); + * ``` + */ +const clearSession = async (): Promise => { + const cookieStore: ReadonlyRequestCookies = await cookies(); + cookieStore.delete(SessionManager.getSessionCookieName()); + cookieStore.delete(SessionManager.getTempSessionCookieName()); + logger.debug('[clearSession] Session cookies cleared.'); +}; + +export default clearSession; diff --git a/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts b/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts index 5acfdbbb1..9e101653c 100644 --- a/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts +++ b/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts @@ -19,6 +19,7 @@ 'use server'; import {IdToken} from '@asgardeo/node'; +import {DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS} from '../../utils/constants'; import {ReadonlyRequestCookies} from 'next/dist/server/web/spec-extension/adapters/request-cookies'; import {cookies} from 'next/headers'; import AsgardeoNextClient from '../../AsgardeoNextClient'; @@ -98,6 +99,8 @@ const handleOAuthCallbackAction = async ( sessionId, ); + const config: AsgardeoNextConfig = asgardeoClient.getConfiguration() as AsgardeoNextConfig; + if (signInResult) { try { const idToken: IdToken = await asgardeoClient.getDecodedIdToken( @@ -105,21 +108,26 @@ const handleOAuthCallbackAction = async ( (signInResult['id_token'] || signInResult['idToken']) as string, ); const accessToken: string = (signInResult['accessToken'] || signInResult['access_token']) as string; + const refreshToken: string = (signInResult['refreshToken'] as string | undefined) ?? ''; const userIdFromToken: string = (idToken.sub || signInResult['sub'] || sessionId) as string; const scopes: string = signInResult['scope'] as string; const organizationId: string | undefined = (idToken['user_org'] || idToken['organization_id']) as | string | undefined; + const expiresIn: number = (signInResult['expiresIn'] as number | undefined) ?? DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS; + const sessionExpirySeconds: number = SessionManager.resolveSessionExpiry(config.sessionExpirySeconds); const sessionToken: string = await SessionManager.createSessionToken( accessToken, userIdFromToken, sessionId, scopes, + expiresIn, + refreshToken, organizationId, ); - cookieStore.set(SessionManager.getSessionCookieName(), sessionToken, SessionManager.getSessionCookieOptions()); + cookieStore.set(SessionManager.getSessionCookieName(), sessionToken, SessionManager.getSessionCookieOptions(sessionExpirySeconds)); cookieStore.delete(SessionManager.getTempSessionCookieName()); } catch (error) { @@ -130,7 +138,6 @@ const handleOAuthCallbackAction = async ( } } - const config: AsgardeoNextConfig = await asgardeoClient.getConfiguration(); const afterSignInUrl: string = config.afterSignInUrl || '/'; return { diff --git a/packages/nextjs/src/server/actions/isSignedIn.ts b/packages/nextjs/src/server/actions/isSignedIn.ts index 6c0d43780..84503434d 100644 --- a/packages/nextjs/src/server/actions/isSignedIn.ts +++ b/packages/nextjs/src/server/actions/isSignedIn.ts @@ -25,29 +25,25 @@ import {SessionTokenPayload} from '../../utils/SessionManager'; /** * Check if the user is currently signed in. - * First tries JWT session validation, then falls back to legacy session check. * - * @param sessionId - Optional session ID to check (if not provided, gets from cookies) - * @returns True if user is signed in, false otherwise + * For JWT-based sessions: the session JWT exp claim is now tied to the access + * token expiry. A successful jwtVerify (inside getSessionPayload) already proves + * exp > now, so no separate timestamp comparison is needed here. + * + * Falls back to the legacy SDK in-memory check when no JWT session cookie exists. + * + * @param sessionId - Optional session ID (used only for the legacy fallback path) + * @returns True if the user is signed in with a valid, non-expired token */ const isSignedIn = async (sessionId?: string): Promise => { try { const sessionPayload: SessionTokenPayload | undefined = await getSessionPayload(); if (sessionPayload) { - const resolvedSessionId: string = sessionPayload.sessionId; - - if (resolvedSessionId) { - const client: AsgardeoNextClient = AsgardeoNextClient.getInstance(); - try { - const accessToken: string = await client.getAccessToken(resolvedSessionId); - return !!accessToken; - } catch (error) { - return false; - } - } + return true; } + // No JWT session — fall back to the legacy SDK in-memory store check. const resolvedSessionId: string | undefined = sessionId || (await getSessionId()); if (!resolvedSessionId) { @@ -58,9 +54,8 @@ const isSignedIn = async (sessionId?: string): Promise => { try { const accessToken: string = await client.getAccessToken(resolvedSessionId); - return !!accessToken; - } catch (error) { + } catch { return false; } } catch { diff --git a/packages/nextjs/src/server/actions/refreshToken.ts b/packages/nextjs/src/server/actions/refreshToken.ts new file mode 100644 index 000000000..01cedb01d --- /dev/null +++ b/packages/nextjs/src/server/actions/refreshToken.ts @@ -0,0 +1,102 @@ +/** + * Copyright (c) 2025, 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. + */ + +'use server'; + +import {AsgardeoAPIError, logger, TokenResponse} from '@asgardeo/node'; +import {ReadonlyRequestCookies} from 'next/dist/server/web/spec-extension/adapters/request-cookies'; +import {cookies} from 'next/headers'; +import AsgardeoNextClient from '../../AsgardeoNextClient'; +import {AsgardeoNextConfig} from '../../models/config'; +import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager'; +import tokenRefreshCore, {TokenRefreshCoreResult} from '../../utils/tokenRefreshCore'; + +/** + * 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. + * + * Delegates the HTTP exchange to tokenRefreshCore 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. + */ +const refreshToken = async (): Promise => { + try { + const cookieStore: ReadonlyRequestCookies = await cookies(); + const sessionToken: string | undefined = cookieStore.get(SessionManager.getSessionCookieName())?.value; + + if (!sessionToken) { + throw new AsgardeoAPIError( + 'No active session found. User must be signed in to refresh the token.', + 'refreshToken-ServerActionError-002', + 'nextjs', + 401, + ); + } + + const sessionPayload: SessionTokenPayload = await SessionManager.verifySessionToken(sessionToken); + const client: AsgardeoNextClient = AsgardeoNextClient.getInstance(); + const config: AsgardeoNextConfig = await (client.getConfiguration() as unknown as Promise); + + const result: TokenRefreshCoreResult = await tokenRefreshCore(sessionPayload, { + baseUrl: config.baseUrl ?? '', + clientId: config.clientId ?? '', + clientSecret: config.clientSecret ?? '', + sessionExpirySeconds: config.sessionExpirySeconds, + }); + + try { + cookieStore.set( + SessionManager.getSessionCookieName(), + result.newSessionToken, + SessionManager.getSessionCookieOptions(result.sessionExpirySeconds), + ); + } catch { + // cookies().set() is only permitted inside a Server Action invoked from the client + // or a Route Handler. When this action is called during SSR rendering the write + // is blocked by Next.js. The middleware refresh path handles that case instead. + logger.warn('[refreshToken] Could not write session cookie — called from SSR rendering context.'); + } + + logger.debug('[refreshToken] Token refresh succeeded.'); + return result.tokenResponse; + } catch (error) { + // Clear the dead session cookie before throwing so the browser is not left + // holding a stale credential. This is best-effort — if called from an SSR + // rendering context Next.js blocks cookie mutation; the middleware cleanup + // path covers that case on the next request. + try { + const cookieStore: ReadonlyRequestCookies = await cookies(); + cookieStore.delete(SessionManager.getSessionCookieName()); + logger.debug('[refreshToken] Cleared session cookie after refresh failure.'); + } catch { + // Intentionally swallowed — middleware handles cleanup when mutation is blocked. + } + + throw new AsgardeoAPIError( + `Failed to refresh the session: ${error instanceof Error ? error.message : JSON.stringify(error)}`, + 'refreshToken-ServerActionError-001', + 'nextjs', + error instanceof AsgardeoAPIError ? error.statusCode : undefined, + ); + } +}; + +export default refreshToken; diff --git a/packages/nextjs/src/server/actions/signInAction.ts b/packages/nextjs/src/server/actions/signInAction.ts index 98ca3412e..e34ccc554 100644 --- a/packages/nextjs/src/server/actions/signInAction.ts +++ b/packages/nextjs/src/server/actions/signInAction.ts @@ -30,6 +30,9 @@ import { import {ReadonlyRequestCookies} from 'next/dist/server/web/spec-extension/adapters/request-cookies'; import {cookies} from 'next/headers'; import AsgardeoNextClient from '../../AsgardeoNextClient'; +import {AsgardeoNextConfig} from '../../models/config'; +import {DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS} from '../../utils/constants'; +import logger from '../../utils/logger'; import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager'; /** @@ -116,23 +119,32 @@ const signInAction = async ( ); if (signInResult) { - const idToken: IdToken = await client.getDecodedIdToken(sessionId); + const idToken: IdToken = await client.getDecodedIdToken( + sessionId, + (signInResult['idToken'] || signInResult['id_token']) as string, + ); const userIdFromToken: string = (idToken['sub'] || signInResult['sub'] || sessionId) as string; const {accessToken}: {accessToken: string} = signInResult as {accessToken: string}; + const refreshToken: string = (signInResult['refreshToken'] as string | undefined) ?? ''; const scopes: string = signInResult['scope'] as string; const organizationId: string | undefined = (idToken['user_org'] || idToken['organization_id']) as | string | undefined; + const expiresIn: number = (signInResult['expiresIn'] as number | undefined) ?? DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS; + const config: AsgardeoNextConfig = client.getConfiguration() as AsgardeoNextConfig; + const sessionExpirySeconds: number = SessionManager.resolveSessionExpiry(config.sessionExpirySeconds); const sessionToken: string = await SessionManager.createSessionToken( accessToken, userIdFromToken, - sessionId as string, + sessionId, scopes, + expiresIn, + refreshToken, organizationId, ); - cookieStore.set(SessionManager.getSessionCookieName(), sessionToken, SessionManager.getSessionCookieOptions()); + cookieStore.set(SessionManager.getSessionCookieName(), sessionToken, SessionManager.getSessionCookieOptions(sessionExpirySeconds)); cookieStore.delete(SessionManager.getTempSessionCookieName()); } @@ -143,8 +155,7 @@ const signInAction = async ( return {data: response as EmbeddedSignInFlowInitiateResponse, success: true}; } catch (error) { - // eslint-disable-next-line no-console - console.error('[signInAction] Error during sign-in:', error); + logger.error(`[signInAction] Error during sign-in: ${error instanceof Error ? error.message : String(error)}`); return {error: String(error), success: false}; } }; diff --git a/packages/nextjs/src/server/actions/switchOrganization.ts b/packages/nextjs/src/server/actions/switchOrganization.ts index 64d095051..6e2f71e8f 100644 --- a/packages/nextjs/src/server/actions/switchOrganization.ts +++ b/packages/nextjs/src/server/actions/switchOrganization.ts @@ -19,6 +19,8 @@ 'use server'; import {Organization, AsgardeoAPIError, IdToken, TokenResponse} from '@asgardeo/node'; +import {AsgardeoNextConfig} from '../../models/config'; +import {DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS} from '../../utils/constants'; import {ReadonlyRequestCookies} from 'next/dist/server/web/spec-extension/adapters/request-cookies'; import {cookies} from 'next/headers'; import getSessionId from './getSessionId'; @@ -39,33 +41,33 @@ const switchOrganization = async ( const resolvedSessionId: string = sessionId ?? ((await getSessionId()) as string); const response: TokenResponse | Response = await client.switchOrganization(organization, resolvedSessionId); - // After switching organization, we need to refresh the page to get updated session data - // This is because server components don't maintain state between function calls const {revalidatePath} = await import('next/cache'); - - // Revalidate the current path to refresh the component with new data revalidatePath('/'); - if (response) { - const idToken: IdToken = await client.getDecodedIdToken(resolvedSessionId, (response as TokenResponse).idToken); + if (response && (response as TokenResponse).accessToken) { + const tokenResponse: TokenResponse = response as TokenResponse; + const idToken: IdToken = await client.getDecodedIdToken(resolvedSessionId, tokenResponse.idToken); const userIdFromToken: string = idToken['sub'] as string; - const {accessToken}: {accessToken: string} = response as TokenResponse; - const scopes: string = (response as TokenResponse).scope; const organizationId: string | undefined = (idToken['user_org'] || idToken['organization_id']) as | string | undefined; + const config: AsgardeoNextConfig = client.getConfiguration() as AsgardeoNextConfig; + const sessionExpirySeconds: number = SessionManager.resolveSessionExpiry(config.sessionExpirySeconds); + const expiresIn: number = tokenResponse.expiresIn ? parseInt(tokenResponse.expiresIn, 10) : DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS; const sessionToken: string = await SessionManager.createSessionToken( - accessToken, - userIdFromToken as string, - resolvedSessionId as string, - scopes, + tokenResponse.accessToken, + userIdFromToken, + resolvedSessionId, + tokenResponse.scope, + expiresIn, + tokenResponse.refreshToken ?? '', organizationId, ); logger.debug('[switchOrganization] Session token created successfully.'); - cookieStore.set(SessionManager.getSessionCookieName(), sessionToken, SessionManager.getSessionCookieOptions()); + cookieStore.set(SessionManager.getSessionCookieName(), sessionToken, SessionManager.getSessionCookieOptions(sessionExpirySeconds)); } return response; diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 7fca31529..ed2d01886 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -23,7 +23,3 @@ export {default as asgardeo} from './asgardeo'; export {default as AsgardeoProvider} from './AsgardeoProvider.js'; export * from './AsgardeoProvider.js'; -export {default as asgardeoMiddleware} from './middleware/asgardeoMiddleware'; -export * from './middleware/asgardeoMiddleware'; - -export {default as createRouteMatcher} from './middleware/createRouteMatcher'; diff --git a/packages/nextjs/src/server/middleware/asgardeoMiddleware.ts b/packages/nextjs/src/server/middleware/asgardeoMiddleware.ts index da9e33bcb..543ac14d9 100644 --- a/packages/nextjs/src/server/middleware/asgardeoMiddleware.ts +++ b/packages/nextjs/src/server/middleware/asgardeoMiddleware.ts @@ -18,9 +18,11 @@ import {NextRequest, NextResponse} from 'next/server'; import {AsgardeoNextConfig} from '../../models/config'; +import decorateConfigWithNextEnv from '../../utils/decorateConfigWithNextEnv'; import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager'; +import {REFRESH_BUFFER_SECONDS} from '../../utils/constants'; +import tokenRefreshCore from '../../utils/tokenRefreshCore'; import { - hasValidSession as hasValidJWTSession, getSessionFromRequest, getSessionIdFromRequest, } from '../../utils/sessionUtils'; @@ -41,7 +43,7 @@ export type AsgardeoMiddlewareContext = { * 2. resolvedOptions.signInUrl * 3. resolvedOptions.defaultRedirect * 4. referer (if from same origin) - * If none are available, throws an error. + * If none are available, falls back to '/'. */ protectRoute: (routeOptions?: {redirect?: string}) => Promise; }; @@ -52,43 +54,79 @@ type AsgardeoMiddlewareHandler = ( ) => Promise | NextResponse | void; /** - * Enhanced session validation that checks both JWT and legacy sessions - * - * @param request - The Next.js request object - * @returns True if a valid session exists, false otherwise + * Removes a named cookie from a raw Cookie header string. */ -const hasValidSession = async (request: NextRequest): Promise => { - try { - return await hasValidJWTSession(request); - } catch { - return Promise.resolve(false); - } -}; +const removeCookieFromHeader = (cookieHeader: string, name: string): string => + cookieHeader + .split(';') + .map(p => p.trim()) + .filter(p => { + const eqIdx: number = p.indexOf('='); + const partName: string = eqIdx === -1 ? p : p.slice(0, eqIdx).trim(); + return partName !== name; + }) + .join('; '); /** - * Gets the session ID from the request cookies. - * Supports both JWT and legacy session formats. - * - * @param request - The Next.js request object - * @returns The session ID if it exists, undefined otherwise + * Replaces the value of a named cookie inside a raw Cookie header string. + * If the cookie does not already appear in the header it is appended. */ -const getSessionIdFromRequestMiddleware = async (request: NextRequest): Promise => - getSessionIdFromRequest(request); +const replaceCookieInHeader = (cookieHeader: string, name: string, value: string): string => { + const parts: string[] = cookieHeader + .split(';') + .map(p => p.trim()) + .filter(Boolean); + + let found: boolean = false; + const updated: string[] = parts.map(part => { + const eqIdx: number = part.indexOf('='); + const partName: string = eqIdx === -1 ? part : part.slice(0, eqIdx).trim(); + if (partName === name) { + found = true; + return `${name}=${value}`; + } + return part; + }); + + if (!found) { + updated.push(`${name}=${value}`); + } + + return updated.join('; '); +}; /** * Asgardeo middleware that integrates authentication into your Next.js application. * Similar to Clerk's clerkMiddleware pattern. * + * Proactively refreshes the access token when it is within REFRESH_BUFFER_SECONDS of + * expiry so that Server Components always receive a fresh session. The refresh also + * recovers expired tokens as long as a refresh token is present. + * + * The updated session cookie is written to: + * - The response → browser stores the new cookie for subsequent requests. + * - The forwarded request headers → the same-request Server Component render sees + * the fresh token immediately without waiting for the next navigation. + * + * Token refresh requires baseUrl, clientId, and clientSecret. These are resolved from + * the options argument first, then from the standard Asgardeo environment variables + * (NEXT_PUBLIC_ASGARDEO_BASE_URL, NEXT_PUBLIC_ASGARDEO_CLIENT_ID, + * ASGARDEO_CLIENT_SECRET). If none are available the refresh step is skipped silently. + * * @param handler - Optional handler function to customize middleware behavior * @param options - Configuration options for the middleware * @returns Next.js middleware function * * @example * ```typescript - * // middleware.ts - Basic usage + * // middleware.ts - Basic usage (config read from env vars automatically) * import { asgardeoMiddleware } from '@asgardeo/nextjs'; * * export default asgardeoMiddleware(); + * + * export const config = { + * matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], + * }; * ``` * * @example @@ -104,32 +142,6 @@ const getSessionIdFromRequestMiddleware = async (request: NextRequest): Promise< * } * }); * ``` - * - * @example - * ```typescript - * // Advanced usage with custom logic - * import { asgardeoMiddleware, createRouteMatcher } from '@asgardeo/nextjs'; - * - * const isProtectedRoute = createRouteMatcher(['/dashboard(.*)']); - * const isAuthRoute = createRouteMatcher(['/sign-in', '/sign-up']); - * - * export default asgardeoMiddleware(async (asgardeo, req) => { - * // Skip protection for auth routes - * if (isAuthRoute(req)) return; - * - * // Protect specified routes - * if (isProtectedRoute(req)) { - * await asgardeo.protectRoute({ redirect: '/sign-in' }); - * } - * - * // Check authentication status - * if (asgardeo.isSignedIn()) { - * console.log('User is authenticated with session:', asgardeo.getSessionId()); - * } - * }, { - * defaultRedirect: '/sign-in' - * }); - * ``` */ const asgardeoMiddleware = ( @@ -137,94 +149,214 @@ const asgardeoMiddleware = options?: AsgardeoMiddlewareOptions | ((req: NextRequest) => AsgardeoMiddlewareOptions), ): ((request: NextRequest) => Promise) => async (request: NextRequest): Promise => { - const resolvedOptions: AsgardeoMiddlewareOptions = typeof options === 'function' ? options(request) : options || {}; + const resolvedOptions: AsgardeoMiddlewareOptions = + typeof options === 'function' ? options(request) : options || {}; + // Resolve full config from passed options + environment variable fallbacks. + const resolvedConfig: AsgardeoNextConfig = decorateConfigWithNextEnv( + resolvedOptions as AsgardeoNextConfig, + ); + + // ── OAuth callback detection ────────────────────────────────────────────── const url: URL = new URL(request.url); const hasCallbackParams: boolean = url.searchParams.has('code') && url.searchParams.has('state'); let isValidOAuthCallback: boolean = false; - if (hasCallbackParams) { - // OAuth callbacks should not contain error parameters that indicate failed auth - const hasError: boolean = url.searchParams.has('error'); - - if (!hasError) { - // Validate that there's a temporary session that initiated this OAuth flow - const tempSessionToken: string | undefined = request.cookies.get( - SessionManager.getTempSessionCookieName(), - )?.value; - if (tempSessionToken) { - try { - // Verify the temporary session exists and is valid - await SessionManager.verifyTempSession(tempSessionToken); - isValidOAuthCallback = true; - } catch { - // Invalid temp session - this is not a legitimate OAuth callback - isValidOAuthCallback = false; - } + if (hasCallbackParams && !url.searchParams.has('error')) { + const tempSessionToken: string | undefined = request.cookies.get( + SessionManager.getTempSessionCookieName(), + )?.value; + if (tempSessionToken) { + try { + await SessionManager.verifyTempSession(tempSessionToken); + isValidOAuthCallback = true; + } catch { + isValidOAuthCallback = false; } } } - const sessionId: string | undefined = await getSessionIdFromRequestMiddleware(request); - const isAuthenticated: boolean = await hasValidSession(request); + // ── Session resolution ──────────────────────────────────────────────────── + // Step 1: Attempt to get a fully verified (signature + expiry) session. + const verifiedSession: SessionTokenPayload | undefined = await getSessionFromRequest(request); - const asgardeo: AsgardeoMiddlewareContext = { - getSession: async (): Promise => { + // Step 2: If no verified session exists, try to decode the raw cookie without + // expiry verification. This allows the middleware to recover from an expired + // access token as long as a refresh token is still present. + let expiredSession: SessionTokenPayload | undefined; + if (!verifiedSession) { + const rawToken: string | undefined = request.cookies.get( + SessionManager.getSessionCookieName(), + )?.value; + if (rawToken) { try { - return await getSessionFromRequest(request); + const decoded: SessionTokenPayload = SessionManager.decodeSessionToken(rawToken); + if (decoded.refreshToken) { + expiredSession = decoded; + } } catch { - return undefined; + // Malformed token — ignore. } - }, + } + } + + // ── Token refresh ───────────────────────────────────────────────────────── + const now: number = Math.floor(Date.now() / 1000); + const candidateSession: SessionTokenPayload | undefined = verifiedSession ?? expiredSession; + + // Config is required to call the token endpoint. + const hasRefreshConfig: boolean = !!( + resolvedConfig.baseUrl && + resolvedConfig.clientId && + resolvedConfig.clientSecret + ); + + // Refresh when: + // a) Token is verified but within the proactive buffer window, OR + // b) Token has already expired but a refresh token is available. + const needsRefresh: boolean = + !isValidOAuthCallback && + hasRefreshConfig && + !!(candidateSession?.refreshToken) && + ( + (!!verifiedSession && verifiedSession.exp <= now + REFRESH_BUFFER_SECONDS) || + !!expiredSession + ); + + let activeSession: SessionTokenPayload | undefined = verifiedSession; + let refreshCookieUpdate: {token: string; expiry: number} | undefined; + + if (needsRefresh && candidateSession) { + try { + const {newSessionToken, sessionExpirySeconds} = await tokenRefreshCore(candidateSession, { + baseUrl: resolvedConfig.baseUrl!, + clientId: resolvedConfig.clientId!, + clientSecret: resolvedConfig.clientSecret!, + sessionExpirySeconds: resolvedConfig.sessionExpirySeconds, + }); + // Verify the newly minted token so activeSession reflects fresh claims. + activeSession = await SessionManager.verifySessionToken(newSessionToken); + refreshCookieUpdate = {token: newSessionToken, expiry: sessionExpirySeconds}; + } catch { + // Refresh failed — clear the irrecoverable session. + activeSession = undefined; + } + } + + // ── Session cleanup detection ───────────────────────────────────────────── + // Mark stale cookies for deletion when the session is irrecoverable. Skipped + // during OAuth callbacks where a session cookie may not exist yet. + const rawSessionCookie: string | undefined = request.cookies.get( + SessionManager.getSessionCookieName(), + )?.value; + + let shouldClearCookie: boolean = false; + + if (!isValidOAuthCallback && rawSessionCookie && !activeSession && !refreshCookieUpdate) { + // A cookie was present but all resolution paths (verify, decode, refresh) + // failed — the session is dead and cannot be recovered. + shouldClearCookie = true; + } + + const sessionId: string | undefined = + activeSession?.sessionId ?? (await getSessionIdFromRequest(request)); + const isAuthenticated: boolean = !!activeSession; + + // ── Middleware context ──────────────────────────────────────────────────── + const asgardeo: AsgardeoMiddlewareContext = { + getSession: async (): Promise => activeSession, getSessionId: (): string | undefined => sessionId, isSignedIn: (): boolean => isAuthenticated, - // eslint-disable-next-line @typescript-eslint/no-unused-vars protectRoute: async (routeOptions?: {redirect?: string}): Promise => { - // Skip protection if this is a validated OAuth callback - let the callback handler process it first - // This prevents race conditions where middleware redirects before OAuth callback completes + // Skip during a valid OAuth callback to avoid redirecting before the + // callback action has had a chance to complete. if (isValidOAuthCallback) { return undefined; } if (!isAuthenticated) { const referer: string | null = request.headers.get('referer'); - // TODO: Make this configurable or call the signIn() from here. let fallbackRedirect: string = '/'; - // If referer exists and is from the same origin, use it as fallback if (referer) { try { const refererUrl: URL = new URL(referer); const requestUrl: URL = new URL(request.url); - if (refererUrl.origin === requestUrl.origin) { fallbackRedirect = refererUrl.pathname + refererUrl.search; } - } catch (error) { - // Invalid referer URL, ignore it + } catch { + // Invalid referer — ignore. } } - // Fallback chain: options.redirect -> resolvedOptions.signInUrl -> resolvedOptions.defaultRedirect -> referer (same origin only) - const redirectUrl: string = (resolvedOptions?.signInUrl as string) || fallbackRedirect; - - const signInUrl: URL = new URL(redirectUrl, request.url); + const redirectUrl: string = + routeOptions?.redirect ?? (resolvedConfig.signInUrl as string) ?? fallbackRedirect; - return NextResponse.redirect(signInUrl); + return NextResponse.redirect(new URL(redirectUrl, request.url)); } return undefined; }, }; + // ── Handler ─────────────────────────────────────────────────────────────── + let handlerResponse: NextResponse | void = undefined; if (handler) { - const result: NextResponse | void = await handler(asgardeo, request); - if (result) { - return result; + handlerResponse = await handler(asgardeo, request); + } + + // ── Build final response ────────────────────────────────────────────────── + if (shouldClearCookie) { + const cookieName: string = SessionManager.getSessionCookieName(); + + if (handlerResponse) { + // Handler returned a response (e.g. a redirect from protectRoute). + // Attach the deletion so the browser discards the stale cookie. + handlerResponse.cookies.delete(cookieName); + return handlerResponse; } + + // Pass-through: strip the dead cookie from the forwarded request headers + // so the same-request Server Component render sees no session at all. + const requestHeaders: Headers = new Headers(request.headers); + requestHeaders.set('cookie', removeCookieFromHeader(request.headers.get('cookie') ?? '', cookieName)); + const cleanResponse: NextResponse = NextResponse.next({request: {headers: requestHeaders}}); + cleanResponse.cookies.delete(cookieName); + return cleanResponse; + } + + if (!refreshCookieUpdate) { + return handlerResponse ?? NextResponse.next(); } - return NextResponse.next(); + // A token refresh occurred — the new session cookie must be applied to: + // 1. The HTTP response so the browser stores the updated cookie. + // 2. The forwarded request headers so the same-request Server Component + // render reads the fresh session token instead of the expired one. + const cookieName: string = SessionManager.getSessionCookieName(); + const cookieOptions = SessionManager.getSessionCookieOptions(refreshCookieUpdate.expiry); + + if (handlerResponse) { + // Handler returned a response (e.g. a redirect from protectRoute). + // Attach the refresh cookie so the browser receives it even on redirects. + handlerResponse.cookies.set(cookieName, refreshCookieUpdate.token, cookieOptions); + return handlerResponse; + } + + // Default pass-through: update both the response cookie and the request + // Cookie header so the downstream Server Component render is not stale. + const requestHeaders: Headers = new Headers(request.headers); + const updatedCookieHeader: string = replaceCookieInHeader( + request.headers.get('cookie') ?? '', + cookieName, + refreshCookieUpdate.token, + ); + requestHeaders.set('cookie', updatedCookieHeader); + + const response: NextResponse = NextResponse.next({request: {headers: requestHeaders}}); + response.cookies.set(cookieName, refreshCookieUpdate.token, cookieOptions); + return response; }; export default asgardeoMiddleware; diff --git a/packages/nextjs/src/utils/SessionManager.ts b/packages/nextjs/src/utils/SessionManager.ts index a83cceb09..dd1bc8e33 100644 --- a/packages/nextjs/src/utils/SessionManager.ts +++ b/packages/nextjs/src/utils/SessionManager.ts @@ -17,18 +17,22 @@ */ import {AsgardeoRuntimeError, CookieConfig} from '@asgardeo/node'; -import {SignJWT, jwtVerify, JWTPayload} from 'jose'; +import {SignJWT, jwtVerify, decodeJwt, JWTPayload} from 'jose'; +import {DEFAULT_SESSION_EXPIRY_SECONDS} from './constants' + /** * Session token payload interface */ export interface SessionTokenPayload extends JWTPayload { - /** Expiration timestamp */ + /** Expiration timestamp — doubles as the access token expiry (JWT exp == access token exp) */ exp: number; /** Issued at timestamp */ iat: number; /** Organization ID if applicable */ organizationId?: string; + /** The refresh token; empty string if not provided by the auth server */ + refreshToken: string; /** OAuth scopes */ scopes: string[]; /** Session ID */ @@ -41,7 +45,6 @@ export interface SessionTokenPayload extends JWTPayload { * Session management utility class for JWT-based session cookies */ class SessionManager { - private static readonly DEFAULT_EXPIRY_SECONDS: number = 3600; /** * Get the signing secret from environment variable @@ -87,21 +90,46 @@ class SessionManager { } /** - * Create a session cookie with user information + * Resolve the session expiry in seconds. + * + * Resolution order (first defined value wins): + * 1. `configuredExpiry` — value from `AsgardeoNextConfig.sessionExpirySeconds` + * 2. `ASGARDEO_SESSION_EXPIRY_SECONDS` environment variable + * 3. `DEFAULT_SESSION_EXPIRY_SECONDS` (24 hours) */ + static resolveSessionExpiry(configuredExpiry?: number): number { + if (configuredExpiry != null && configuredExpiry > 0) { + return configuredExpiry; + } + + const envValue: string | undefined = process.env['ASGARDEO_SESSION_EXPIRY_SECONDS']; + + if (envValue) { + const parsed: number = parseInt(envValue, 10); + + if (!isNaN(parsed) && parsed > 0) { + return parsed; + } + } + + return DEFAULT_SESSION_EXPIRY_SECONDS; + } + static async createSessionToken( accessToken: string, userId: string, sessionId: string, scopes: string, + accessTokenTtlSeconds: number, + refreshToken: string, organizationId?: string, - expirySeconds: number = this.DEFAULT_EXPIRY_SECONDS, ): Promise { const secret: Uint8Array = this.getSecret(); const jwt: string = await new SignJWT({ accessToken, organizationId, + refreshToken, scopes, sessionId, type: 'session', @@ -109,12 +137,21 @@ class SessionManager { .setProtectedHeader({alg: 'HS256'}) .setSubject(userId) .setIssuedAt() - .setExpirationTime(Date.now() / 1000 + expirySeconds) + .setExpirationTime(Math.floor(Date.now() / 1000) + accessTokenTtlSeconds) .sign(secret); return jwt; } + /** + * Decode a session token without verifying the signature or expiry. + * Use only to inspect an expired token (e.g. to extract a refresh token for renewal). + * Never use the result to make authorization decisions. + */ + static decodeSessionToken(token: string): SessionTokenPayload { + return decodeJwt(token) as SessionTokenPayload; + } + /** * Verify and decode a session token */ @@ -160,7 +197,7 @@ class SessionManager { /** * Get session cookie options */ - static getSessionCookieOptions(): { + static getSessionCookieOptions(maxAge: number): { httpOnly: boolean; maxAge: number; path: string; @@ -169,7 +206,7 @@ class SessionManager { } { return { httpOnly: true, - maxAge: this.DEFAULT_EXPIRY_SECONDS, + maxAge, path: '/', sameSite: 'lax' as const, secure: process.env['NODE_ENV'] === 'production', diff --git a/packages/nextjs/src/utils/constants.ts b/packages/nextjs/src/utils/constants.ts new file mode 100644 index 000000000..3f939b28b --- /dev/null +++ b/packages/nextjs/src/utils/constants.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2025, 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. + */ + +/** + * Number of seconds before access token expiry at which the SDK proactively + * refreshes the token. A 60-second buffer prevents races where the token is + * valid when a request starts but expires mid-flight. + */ +export const REFRESH_BUFFER_SECONDS: number = 60; + +/** + * Default access token lifetime in seconds (1 hour). + * Used as a fallback when the token endpoint does not return an `expires_in` value. + */ +export const DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS: number = 3600; + +/** + * Default session lifetime in seconds (24 hours). + * + * Used when no explicit session expiry is configured. The session lifetime + * can be overridden in two ways (evaluated in this order): + * + * 1. `sessionExpirySeconds` in `AsgardeoNextConfig` — set programmatically + * when initialising the SDK. + * 2. `ASGARDEO_SESSION_EXPIRY_SECONDS` environment variable — set in `.env` + * (e.g. `ASGARDEO_SESSION_EXPIRY_SECONDS=86400`). + * 3. This constant — applied when neither of the above is present. + * + * The JWT `exp` claim and the browser cookie `maxAge` are always set to the + * same resolved value so they expire together. + */ +export const DEFAULT_SESSION_EXPIRY_SECONDS: number = 86400; diff --git a/packages/nextjs/src/utils/tokenRefreshCore.ts b/packages/nextjs/src/utils/tokenRefreshCore.ts new file mode 100644 index 000000000..34a4bc036 --- /dev/null +++ b/packages/nextjs/src/utils/tokenRefreshCore.ts @@ -0,0 +1,131 @@ +/** + * Copyright (c) 2025, 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 type {TokenResponse} from '@asgardeo/node'; +import {DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS} from './constants'; +import SessionManager, {SessionTokenPayload} from './SessionManager'; + +/** + * Config required to call the token endpoint. + */ +export interface TokenRefreshCoreConfig { + baseUrl: string; + clientId: string; + clientSecret: string; + sessionExpirySeconds?: number; +} + +/** + * Result returned by tokenRefreshCore. + * Callers are responsible for persisting newSessionToken in the appropriate cookie context. + */ +export interface TokenRefreshCoreResult { + newSessionToken: string; + sessionExpirySeconds: number; + tokenResponse: TokenResponse; +} + +/** + * Core token refresh logic — performs the OAuth refresh_token grant and builds a new + * session JWT string. + * + * Intentionally decoupled from cookie APIs so it can be called from both the Edge + * Runtime (Next.js middleware) and the Node.js Runtime (server actions). + * Cookie persistence is the caller's responsibility. + */ +const tokenRefreshCore = async ( + sessionPayload: SessionTokenPayload, + config: TokenRefreshCoreConfig, +): Promise => { + const {baseUrl, clientId, clientSecret, sessionExpirySeconds: configuredExpiry} = config; + const {refreshToken: storedRefreshToken, sessionId, sub, scopes, organizationId} = sessionPayload; + + if (!storedRefreshToken) { + throw new Error('No refresh token found in session payload.'); + } + + const tokenEndpoint: string = `${baseUrl}/oauth2/token`; + const body: URLSearchParams = new URLSearchParams({ + client_id: clientId ?? '', + client_secret: clientSecret ?? '', + grant_type: 'refresh_token', + refresh_token: storedRefreshToken, + }); + + let response: Response; + + try { + response = await fetch(tokenEndpoint, { + body: body.toString(), + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + method: 'POST', + }); + } catch (fetchError) { + throw new Error( + `Token refresh network error: ${fetchError instanceof Error ? fetchError.message : String(fetchError)}`, + ); + } + + if (!response.ok) { + throw new Error(`Token endpoint rejected refresh (HTTP ${response.status}).`); + } + + let tokenData: Record; + + try { + tokenData = (await response.json()) as Record; + } catch { + throw new Error('Failed to parse token endpoint response as JSON.'); + } + + const newAccessToken: string = tokenData['access_token'] as string; + const expiresIn: number = (tokenData['expires_in'] as number | undefined) ?? DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS; + // Use the rotated refresh token if the server provided one; otherwise keep the existing one. + const newRefreshToken: string = (tokenData['refresh_token'] as string | undefined) ?? storedRefreshToken; + const newScopes: string = + (tokenData['scope'] as string | undefined) ?? + (Array.isArray(scopes) ? scopes.join(' ') : (scopes as string) ?? ''); + + const resolvedSessionExpiry: number = SessionManager.resolveSessionExpiry(configuredExpiry); + + const newSessionToken: string = await SessionManager.createSessionToken( + newAccessToken, + sub as string, + sessionId, + newScopes, + expiresIn, + newRefreshToken, + organizationId, + ); + + return { + newSessionToken, + sessionExpirySeconds: resolvedSessionExpiry, + tokenResponse: { + accessToken: newAccessToken, + createdAt: Math.floor(Date.now() / 1000), + expiresIn: String(expiresIn), + idToken: (tokenData['id_token'] as string | undefined) ?? '', + refreshToken: newRefreshToken, + scope: newScopes, + tokenType: (tokenData['token_type'] as string | undefined) ?? 'Bearer', + }, + }; +}; + +export default tokenRefreshCore; From 9f4f03623dd4b4db2ef9885e4944bb76c66be197 Mon Sep 17 00:00:00 2001 From: kavindadewmith Date: Sun, 19 Apr 2026 14:50:39 +0530 Subject: [PATCH 02/12] Fix lint errors --- packages/javascript/esbuild.config.mjs | 6 +-- .../contexts/Asgardeo/AsgardeoProvider.tsx | 6 +-- .../nextjs/src/server/AsgardeoProvider.tsx | 4 +- .../nextjs/src/server/actions/clearSession.ts | 2 +- .../actions/handleOAuthCallbackAction.ts | 11 ++-- .../nextjs/src/server/actions/signInAction.ts | 9 +++- .../src/server/actions/switchOrganization.ts | 14 +++-- packages/nextjs/src/server/index.ts | 1 - .../server/middleware/asgardeoMiddleware.ts | 52 +++++++------------ packages/nextjs/src/utils/SessionManager.ts | 6 +-- packages/nextjs/src/utils/tokenRefreshCore.ts | 3 +- 11 files changed, 55 insertions(+), 59 deletions(-) diff --git a/packages/javascript/esbuild.config.mjs b/packages/javascript/esbuild.config.mjs index 77091aa78..1fc15925f 100644 --- a/packages/javascript/esbuild.config.mjs +++ b/packages/javascript/esbuild.config.mjs @@ -45,14 +45,14 @@ await esbuild.build({ await esbuild.build({ ...commonOptions, - format: 'esm', - outfile: 'dist/edge/index.js', - platform: 'browser', define: { // process.versions is a Node.js-only API not available in the Edge Runtime. // Replacing it with undefined makes isNode() evaluate to false, causing the // logger to fall back to plain console.log() — the correct behaviour for Edge. 'process.versions': 'undefined', }, + format: 'esm', + outfile: 'dist/edge/index.js', + platform: 'browser', sourcemap: true, }); diff --git a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx index 2f8463469..b340d37db 100644 --- a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx +++ b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx @@ -57,6 +57,7 @@ export type AsgardeoClientProviderProps = Partial & { applicationId: AsgardeoContextProps['applicationId']; brandingPreference?: BrandingPreference | null; + clearSession: () => Promise; createOrganization: (payload: CreateOrganizationPayload, sessionId: string) => Promise; currentOrganization: Organization; getAllOrganizations: (options?: any, sessionId?: string) => Promise; @@ -65,13 +66,12 @@ export type AsgardeoClientProviderProps = Partial Promise<{error?: string; redirectUrl?: string; success: boolean}>; - clearSession: () => Promise; isSignedIn: boolean; myOrganizations: Organization[]; organizationHandle: AsgardeoContextProps['organizationHandle']; + refreshToken: () => Promise; revalidateMyOrganizations?: (sessionId?: string) => Promise; signIn: AsgardeoContextProps['signIn']; - refreshToken: () => Promise; signOut: AsgardeoContextProps['signOut']; signUp: AsgardeoContextProps['signUp']; switchOrganization: (organization: Organization, sessionId?: string) => Promise; @@ -300,8 +300,8 @@ const AsgardeoClientProvider: FC> isLoading, isSignedIn, organizationHandle, - signIn: handleSignIn, refreshToken, + signIn: handleSignIn, signInUrl, signOut: handleSignOut, signUp: handleSignUp, diff --git a/packages/nextjs/src/server/AsgardeoProvider.tsx b/packages/nextjs/src/server/AsgardeoProvider.tsx index b5d833d05..613ffe25d 100644 --- a/packages/nextjs/src/server/AsgardeoProvider.tsx +++ b/packages/nextjs/src/server/AsgardeoProvider.tsx @@ -21,6 +21,7 @@ import {BrandingPreference, AsgardeoRuntimeError, IdToken, Organization, User, UserProfile} from '@asgardeo/node'; import {AsgardeoProviderProps} from '@asgardeo/react'; import {FC, PropsWithChildren, ReactElement} from 'react'; +import clearSession from './actions/clearSession'; import createOrganization from './actions/createOrganization'; import getAllOrganizations from './actions/getAllOrganizations'; import getBrandingPreference from './actions/getBrandingPreference'; @@ -32,9 +33,8 @@ import getUserAction from './actions/getUserAction'; import getUserProfileAction from './actions/getUserProfileAction'; import handleOAuthCallbackAction from './actions/handleOAuthCallbackAction'; import isSignedIn from './actions/isSignedIn'; -import signInAction from './actions/signInAction'; -import clearSession from './actions/clearSession'; import refreshToken from './actions/refreshToken'; +import signInAction from './actions/signInAction'; import signOutAction from './actions/signOutAction'; import signUpAction from './actions/signUpAction'; import switchOrganization from './actions/switchOrganization'; diff --git a/packages/nextjs/src/server/actions/clearSession.ts b/packages/nextjs/src/server/actions/clearSession.ts index 8c9d6563d..4ba15a68e 100644 --- a/packages/nextjs/src/server/actions/clearSession.ts +++ b/packages/nextjs/src/server/actions/clearSession.ts @@ -20,8 +20,8 @@ import {ReadonlyRequestCookies} from 'next/dist/server/web/spec-extension/adapters/request-cookies'; import {cookies} from 'next/headers'; -import SessionManager from '../../utils/SessionManager'; import logger from '../../utils/logger'; +import SessionManager from '../../utils/SessionManager'; /** * Deletes all Asgardeo session cookies from the browser without contacting the diff --git a/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts b/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts index 9e101653c..f5c8db5b2 100644 --- a/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts +++ b/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts @@ -19,11 +19,11 @@ 'use server'; import {IdToken} from '@asgardeo/node'; -import {DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS} from '../../utils/constants'; import {ReadonlyRequestCookies} from 'next/dist/server/web/spec-extension/adapters/request-cookies'; import {cookies} from 'next/headers'; import AsgardeoNextClient from '../../AsgardeoNextClient'; import {AsgardeoNextConfig} from '../../models/config'; +import {DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS} from '../../utils/constants'; import logger from '../../utils/logger'; import SessionManager from '../../utils/SessionManager'; @@ -114,7 +114,8 @@ const handleOAuthCallbackAction = async ( const organizationId: string | undefined = (idToken['user_org'] || idToken['organization_id']) as | string | undefined; - const expiresIn: number = (signInResult['expiresIn'] as number | undefined) ?? DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS; + const expiresIn: number = + (signInResult['expiresIn'] as number | undefined) ?? DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS; const sessionExpirySeconds: number = SessionManager.resolveSessionExpiry(config.sessionExpirySeconds); const sessionToken: string = await SessionManager.createSessionToken( @@ -127,7 +128,11 @@ const handleOAuthCallbackAction = async ( organizationId, ); - cookieStore.set(SessionManager.getSessionCookieName(), sessionToken, SessionManager.getSessionCookieOptions(sessionExpirySeconds)); + cookieStore.set( + SessionManager.getSessionCookieName(), + sessionToken, + SessionManager.getSessionCookieOptions(sessionExpirySeconds), + ); cookieStore.delete(SessionManager.getTempSessionCookieName()); } catch (error) { diff --git a/packages/nextjs/src/server/actions/signInAction.ts b/packages/nextjs/src/server/actions/signInAction.ts index e34ccc554..12b78ba75 100644 --- a/packages/nextjs/src/server/actions/signInAction.ts +++ b/packages/nextjs/src/server/actions/signInAction.ts @@ -130,7 +130,8 @@ const signInAction = async ( const organizationId: string | undefined = (idToken['user_org'] || idToken['organization_id']) as | string | undefined; - const expiresIn: number = (signInResult['expiresIn'] as number | undefined) ?? DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS; + const expiresIn: number = + (signInResult['expiresIn'] as number | undefined) ?? DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS; const config: AsgardeoNextConfig = client.getConfiguration() as AsgardeoNextConfig; const sessionExpirySeconds: number = SessionManager.resolveSessionExpiry(config.sessionExpirySeconds); @@ -144,7 +145,11 @@ const signInAction = async ( organizationId, ); - cookieStore.set(SessionManager.getSessionCookieName(), sessionToken, SessionManager.getSessionCookieOptions(sessionExpirySeconds)); + cookieStore.set( + SessionManager.getSessionCookieName(), + sessionToken, + SessionManager.getSessionCookieOptions(sessionExpirySeconds), + ); cookieStore.delete(SessionManager.getTempSessionCookieName()); } diff --git a/packages/nextjs/src/server/actions/switchOrganization.ts b/packages/nextjs/src/server/actions/switchOrganization.ts index 6e2f71e8f..e01de6b1b 100644 --- a/packages/nextjs/src/server/actions/switchOrganization.ts +++ b/packages/nextjs/src/server/actions/switchOrganization.ts @@ -19,12 +19,12 @@ 'use server'; import {Organization, AsgardeoAPIError, IdToken, TokenResponse} from '@asgardeo/node'; -import {AsgardeoNextConfig} from '../../models/config'; -import {DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS} from '../../utils/constants'; import {ReadonlyRequestCookies} from 'next/dist/server/web/spec-extension/adapters/request-cookies'; import {cookies} from 'next/headers'; import getSessionId from './getSessionId'; import AsgardeoNextClient from '../../AsgardeoNextClient'; +import {AsgardeoNextConfig} from '../../models/config'; +import {DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS} from '../../utils/constants'; import logger from '../../utils/logger'; import SessionManager from '../../utils/SessionManager'; @@ -53,7 +53,9 @@ const switchOrganization = async ( | undefined; const config: AsgardeoNextConfig = client.getConfiguration() as AsgardeoNextConfig; const sessionExpirySeconds: number = SessionManager.resolveSessionExpiry(config.sessionExpirySeconds); - const expiresIn: number = tokenResponse.expiresIn ? parseInt(tokenResponse.expiresIn, 10) : DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS; + const expiresIn: number = tokenResponse.expiresIn + ? parseInt(tokenResponse.expiresIn, 10) + : DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS; const sessionToken: string = await SessionManager.createSessionToken( tokenResponse.accessToken, @@ -67,7 +69,11 @@ const switchOrganization = async ( logger.debug('[switchOrganization] Session token created successfully.'); - cookieStore.set(SessionManager.getSessionCookieName(), sessionToken, SessionManager.getSessionCookieOptions(sessionExpirySeconds)); + cookieStore.set( + SessionManager.getSessionCookieName(), + sessionToken, + SessionManager.getSessionCookieOptions(sessionExpirySeconds), + ); } return response; diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index ed2d01886..49f3ec784 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -22,4 +22,3 @@ export {default as asgardeo} from './asgardeo'; export {default as AsgardeoProvider} from './AsgardeoProvider.js'; export * from './AsgardeoProvider.js'; - diff --git a/packages/nextjs/src/server/middleware/asgardeoMiddleware.ts b/packages/nextjs/src/server/middleware/asgardeoMiddleware.ts index 543ac14d9..c5520b493 100644 --- a/packages/nextjs/src/server/middleware/asgardeoMiddleware.ts +++ b/packages/nextjs/src/server/middleware/asgardeoMiddleware.ts @@ -18,14 +18,11 @@ import {NextRequest, NextResponse} from 'next/server'; import {AsgardeoNextConfig} from '../../models/config'; +import {REFRESH_BUFFER_SECONDS} from '../../utils/constants'; import decorateConfigWithNextEnv from '../../utils/decorateConfigWithNextEnv'; import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager'; -import {REFRESH_BUFFER_SECONDS} from '../../utils/constants'; +import {getSessionFromRequest, getSessionIdFromRequest} from '../../utils/sessionUtils'; import tokenRefreshCore from '../../utils/tokenRefreshCore'; -import { - getSessionFromRequest, - getSessionIdFromRequest, -} from '../../utils/sessionUtils'; export type AsgardeoMiddlewareOptions = Partial; @@ -59,8 +56,8 @@ type AsgardeoMiddlewareHandler = ( const removeCookieFromHeader = (cookieHeader: string, name: string): string => cookieHeader .split(';') - .map(p => p.trim()) - .filter(p => { + .map((p: string) => p.trim()) + .filter((p: string) => { const eqIdx: number = p.indexOf('='); const partName: string = eqIdx === -1 ? p : p.slice(0, eqIdx).trim(); return partName !== name; @@ -74,11 +71,11 @@ const removeCookieFromHeader = (cookieHeader: string, name: string): string => const replaceCookieInHeader = (cookieHeader: string, name: string, value: string): string => { const parts: string[] = cookieHeader .split(';') - .map(p => p.trim()) + .map((p: string) => p.trim()) .filter(Boolean); let found: boolean = false; - const updated: string[] = parts.map(part => { + const updated: string[] = parts.map((part: string) => { const eqIdx: number = part.indexOf('='); const partName: string = eqIdx === -1 ? part : part.slice(0, eqIdx).trim(); if (partName === name) { @@ -149,13 +146,10 @@ const asgardeoMiddleware = options?: AsgardeoMiddlewareOptions | ((req: NextRequest) => AsgardeoMiddlewareOptions), ): ((request: NextRequest) => Promise) => async (request: NextRequest): Promise => { - const resolvedOptions: AsgardeoMiddlewareOptions = - typeof options === 'function' ? options(request) : options || {}; + const resolvedOptions: AsgardeoMiddlewareOptions = typeof options === 'function' ? options(request) : options || {}; // Resolve full config from passed options + environment variable fallbacks. - const resolvedConfig: AsgardeoNextConfig = decorateConfigWithNextEnv( - resolvedOptions as AsgardeoNextConfig, - ); + const resolvedConfig: AsgardeoNextConfig = decorateConfigWithNextEnv(resolvedOptions as AsgardeoNextConfig); // ── OAuth callback detection ────────────────────────────────────────────── const url: URL = new URL(request.url); @@ -185,9 +179,7 @@ const asgardeoMiddleware = // access token as long as a refresh token is still present. let expiredSession: SessionTokenPayload | undefined; if (!verifiedSession) { - const rawToken: string | undefined = request.cookies.get( - SessionManager.getSessionCookieName(), - )?.value; + const rawToken: string | undefined = request.cookies.get(SessionManager.getSessionCookieName())?.value; if (rawToken) { try { const decoded: SessionTokenPayload = SessionManager.decodeSessionToken(rawToken); @@ -217,14 +209,11 @@ const asgardeoMiddleware = const needsRefresh: boolean = !isValidOAuthCallback && hasRefreshConfig && - !!(candidateSession?.refreshToken) && - ( - (!!verifiedSession && verifiedSession.exp <= now + REFRESH_BUFFER_SECONDS) || - !!expiredSession - ); + !!candidateSession?.refreshToken && + ((!!verifiedSession && verifiedSession.exp <= now + REFRESH_BUFFER_SECONDS) || !!expiredSession); let activeSession: SessionTokenPayload | undefined = verifiedSession; - let refreshCookieUpdate: {token: string; expiry: number} | undefined; + let refreshCookieUpdate: {expiry: number; token: string} | undefined; if (needsRefresh && candidateSession) { try { @@ -236,7 +225,7 @@ const asgardeoMiddleware = }); // Verify the newly minted token so activeSession reflects fresh claims. activeSession = await SessionManager.verifySessionToken(newSessionToken); - refreshCookieUpdate = {token: newSessionToken, expiry: sessionExpirySeconds}; + refreshCookieUpdate = {expiry: sessionExpirySeconds, token: newSessionToken}; } catch { // Refresh failed — clear the irrecoverable session. activeSession = undefined; @@ -246,9 +235,7 @@ const asgardeoMiddleware = // ── Session cleanup detection ───────────────────────────────────────────── // Mark stale cookies for deletion when the session is irrecoverable. Skipped // during OAuth callbacks where a session cookie may not exist yet. - const rawSessionCookie: string | undefined = request.cookies.get( - SessionManager.getSessionCookieName(), - )?.value; + const rawSessionCookie: string | undefined = request.cookies.get(SessionManager.getSessionCookieName())?.value; let shouldClearCookie: boolean = false; @@ -258,8 +245,7 @@ const asgardeoMiddleware = shouldClearCookie = true; } - const sessionId: string | undefined = - activeSession?.sessionId ?? (await getSessionIdFromRequest(request)); + const sessionId: string | undefined = activeSession?.sessionId ?? (await getSessionIdFromRequest(request)); const isAuthenticated: boolean = !!activeSession; // ── Middleware context ──────────────────────────────────────────────────── @@ -301,10 +287,7 @@ const asgardeoMiddleware = }; // ── Handler ─────────────────────────────────────────────────────────────── - let handlerResponse: NextResponse | void = undefined; - if (handler) { - handlerResponse = await handler(asgardeo, request); - } + const handlerResponse: NextResponse | void = handler ? await handler(asgardeo, request) : undefined; // ── Build final response ────────────────────────────────────────────────── if (shouldClearCookie) { @@ -335,7 +318,8 @@ const asgardeoMiddleware = // 2. The forwarded request headers so the same-request Server Component // render reads the fresh session token instead of the expired one. const cookieName: string = SessionManager.getSessionCookieName(); - const cookieOptions = SessionManager.getSessionCookieOptions(refreshCookieUpdate.expiry); + const cookieOptions: ReturnType = + SessionManager.getSessionCookieOptions(refreshCookieUpdate.expiry); if (handlerResponse) { // Handler returned a response (e.g. a redirect from protectRoute). diff --git a/packages/nextjs/src/utils/SessionManager.ts b/packages/nextjs/src/utils/SessionManager.ts index dd1bc8e33..ebe929c79 100644 --- a/packages/nextjs/src/utils/SessionManager.ts +++ b/packages/nextjs/src/utils/SessionManager.ts @@ -18,8 +18,7 @@ import {AsgardeoRuntimeError, CookieConfig} from '@asgardeo/node'; import {SignJWT, jwtVerify, decodeJwt, JWTPayload} from 'jose'; -import {DEFAULT_SESSION_EXPIRY_SECONDS} from './constants' - +import {DEFAULT_SESSION_EXPIRY_SECONDS} from './constants'; /** * Session token payload interface @@ -45,7 +44,6 @@ export interface SessionTokenPayload extends JWTPayload { * Session management utility class for JWT-based session cookies */ class SessionManager { - /** * Get the signing secret from environment variable * Throws error in production if not set @@ -107,7 +105,7 @@ class SessionManager { if (envValue) { const parsed: number = parseInt(envValue, 10); - if (!isNaN(parsed) && parsed > 0) { + if (!Number.isNaN(parsed) && parsed > 0) { return parsed; } } diff --git a/packages/nextjs/src/utils/tokenRefreshCore.ts b/packages/nextjs/src/utils/tokenRefreshCore.ts index 34a4bc036..10fe7b7d5 100644 --- a/packages/nextjs/src/utils/tokenRefreshCore.ts +++ b/packages/nextjs/src/utils/tokenRefreshCore.ts @@ -98,8 +98,7 @@ const tokenRefreshCore = async ( // Use the rotated refresh token if the server provided one; otherwise keep the existing one. const newRefreshToken: string = (tokenData['refresh_token'] as string | undefined) ?? storedRefreshToken; const newScopes: string = - (tokenData['scope'] as string | undefined) ?? - (Array.isArray(scopes) ? scopes.join(' ') : (scopes as string) ?? ''); + (tokenData['scope'] as string | undefined) ?? (Array.isArray(scopes) ? scopes.join(' ') : (scopes as string) ?? ''); const resolvedSessionExpiry: number = SessionManager.resolveSessionExpiry(configuredExpiry); From e26fb5124e2a9e5834df46eb066c571bf151a068 Mon Sep 17 00:00:00 2001 From: kavindadewmith Date: Mon, 20 Apr 2026 11:26:06 +0530 Subject: [PATCH 03/12] Refactor cookie handling to use new RequestCookies type across session management actions --- packages/nextjs/src/server/actions/clearSession.ts | 5 +++-- packages/nextjs/src/server/actions/getAccessToken.ts | 5 +++-- packages/nextjs/src/server/actions/getSessionId.ts | 5 +++-- packages/nextjs/src/server/actions/getSessionPayload.ts | 5 +++-- .../nextjs/src/server/actions/handleOAuthCallbackAction.ts | 5 +++-- packages/nextjs/src/server/actions/refreshToken.ts | 7 ++++--- packages/nextjs/src/server/actions/signInAction.ts | 5 +++-- packages/nextjs/src/server/actions/signOutAction.ts | 5 +++-- packages/nextjs/src/server/actions/switchOrganization.ts | 5 +++-- packages/nextjs/src/utils/SessionManager.ts | 6 ++++++ packages/nextjs/src/utils/constants.ts | 2 +- 11 files changed, 35 insertions(+), 20 deletions(-) diff --git a/packages/nextjs/src/server/actions/clearSession.ts b/packages/nextjs/src/server/actions/clearSession.ts index 4ba15a68e..cdb79d42a 100644 --- a/packages/nextjs/src/server/actions/clearSession.ts +++ b/packages/nextjs/src/server/actions/clearSession.ts @@ -18,11 +18,12 @@ 'use server'; -import {ReadonlyRequestCookies} from 'next/dist/server/web/spec-extension/adapters/request-cookies'; import {cookies} from 'next/headers'; import logger from '../../utils/logger'; import SessionManager from '../../utils/SessionManager'; +type RequestCookies = Awaited>; + /** * Deletes all Asgardeo session cookies from the browser without contacting the * identity server. @@ -44,7 +45,7 @@ import SessionManager from '../../utils/SessionManager'; * ``` */ const clearSession = async (): Promise => { - const cookieStore: ReadonlyRequestCookies = await cookies(); + const cookieStore: RequestCookies = await cookies(); cookieStore.delete(SessionManager.getSessionCookieName()); cookieStore.delete(SessionManager.getTempSessionCookieName()); logger.debug('[clearSession] Session cookies cleared.'); diff --git a/packages/nextjs/src/server/actions/getAccessToken.ts b/packages/nextjs/src/server/actions/getAccessToken.ts index 9d7a351fe..d7d9b016b 100644 --- a/packages/nextjs/src/server/actions/getAccessToken.ts +++ b/packages/nextjs/src/server/actions/getAccessToken.ts @@ -18,17 +18,18 @@ 'use server'; -import {ReadonlyRequestCookies} from 'next/dist/server/web/spec-extension/adapters/request-cookies'; import {cookies} from 'next/headers'; import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager'; +type RequestCookies = Awaited>; + /** * Get the access token from the session cookie. * * @returns The access token if it exists, undefined otherwise */ const getAccessToken = async (): Promise => { - const cookieStore: ReadonlyRequestCookies = await cookies(); + const cookieStore: RequestCookies = await cookies(); const sessionToken: string | undefined = cookieStore.get(SessionManager.getSessionCookieName())?.value; diff --git a/packages/nextjs/src/server/actions/getSessionId.ts b/packages/nextjs/src/server/actions/getSessionId.ts index e3b598c30..41f23aef6 100644 --- a/packages/nextjs/src/server/actions/getSessionId.ts +++ b/packages/nextjs/src/server/actions/getSessionId.ts @@ -18,10 +18,11 @@ 'use server'; -import {ReadonlyRequestCookies} from 'next/dist/server/web/spec-extension/adapters/request-cookies'; import {cookies} from 'next/headers'; import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager'; +type RequestCookies = Awaited>; + /** * Get the session ID from cookies. * Tries JWT session first, then falls back to legacy session ID. @@ -29,7 +30,7 @@ import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager'; * @returns The session ID if it exists, undefined otherwise */ const getSessionId = async (): Promise => { - const cookieStore: ReadonlyRequestCookies = await cookies(); + const cookieStore: RequestCookies = await cookies(); const sessionToken: string | undefined = cookieStore.get(SessionManager.getSessionCookieName())?.value; diff --git a/packages/nextjs/src/server/actions/getSessionPayload.ts b/packages/nextjs/src/server/actions/getSessionPayload.ts index 7cdea1d73..612147ef7 100644 --- a/packages/nextjs/src/server/actions/getSessionPayload.ts +++ b/packages/nextjs/src/server/actions/getSessionPayload.ts @@ -18,10 +18,11 @@ 'use server'; -import {ReadonlyRequestCookies} from 'next/dist/server/web/spec-extension/adapters/request-cookies'; import {cookies} from 'next/headers'; import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager'; +type RequestCookies = Awaited>; + /** * Get the session payload from JWT session cookie. * This includes user ID, session ID, scopes, and organization ID. @@ -29,7 +30,7 @@ import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager'; * @returns The session payload if valid JWT session exists, undefined otherwise */ const getSessionPayload = async (): Promise => { - const cookieStore: ReadonlyRequestCookies = await cookies(); + const cookieStore: RequestCookies = await cookies(); const sessionToken: string | undefined = cookieStore.get(SessionManager.getSessionCookieName())?.value; if (!sessionToken) { diff --git a/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts b/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts index f5c8db5b2..8901629ba 100644 --- a/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts +++ b/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts @@ -19,7 +19,6 @@ 'use server'; import {IdToken} from '@asgardeo/node'; -import {ReadonlyRequestCookies} from 'next/dist/server/web/spec-extension/adapters/request-cookies'; import {cookies} from 'next/headers'; import AsgardeoNextClient from '../../AsgardeoNextClient'; import {AsgardeoNextConfig} from '../../models/config'; @@ -27,6 +26,8 @@ import {DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS} from '../../utils/constants'; import logger from '../../utils/logger'; import SessionManager from '../../utils/SessionManager'; +type RequestCookies = Awaited>; + /** * Server action to handle OAuth callback with authorization code. * This action processes the authorization code received from the OAuth provider @@ -63,7 +64,7 @@ const handleOAuthCallbackAction = async ( }; } - const cookieStore: ReadonlyRequestCookies = await cookies(); + const cookieStore: RequestCookies = await cookies(); let sessionId: string | undefined; const tempSessionToken: string | undefined = cookieStore.get(SessionManager.getTempSessionCookieName())?.value; diff --git a/packages/nextjs/src/server/actions/refreshToken.ts b/packages/nextjs/src/server/actions/refreshToken.ts index 01cedb01d..748ab66d3 100644 --- a/packages/nextjs/src/server/actions/refreshToken.ts +++ b/packages/nextjs/src/server/actions/refreshToken.ts @@ -19,13 +19,14 @@ 'use server'; import {AsgardeoAPIError, logger, TokenResponse} from '@asgardeo/node'; -import {ReadonlyRequestCookies} from 'next/dist/server/web/spec-extension/adapters/request-cookies'; import {cookies} from 'next/headers'; import AsgardeoNextClient from '../../AsgardeoNextClient'; import {AsgardeoNextConfig} from '../../models/config'; import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager'; import tokenRefreshCore, {TokenRefreshCoreResult} from '../../utils/tokenRefreshCore'; +type RequestCookies = Awaited>; + /** * 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. @@ -39,7 +40,7 @@ import tokenRefreshCore, {TokenRefreshCoreResult} from '../../utils/tokenRefresh */ const refreshToken = async (): Promise => { try { - const cookieStore: ReadonlyRequestCookies = await cookies(); + const cookieStore: RequestCookies = await cookies(); const sessionToken: string | undefined = cookieStore.get(SessionManager.getSessionCookieName())?.value; if (!sessionToken) { @@ -83,7 +84,7 @@ const refreshToken = async (): Promise => { // rendering context Next.js blocks cookie mutation; the middleware cleanup // path covers that case on the next request. try { - const cookieStore: ReadonlyRequestCookies = await cookies(); + const cookieStore: RequestCookies = await cookies(); cookieStore.delete(SessionManager.getSessionCookieName()); logger.debug('[refreshToken] Cleared session cookie after refresh failure.'); } catch { diff --git a/packages/nextjs/src/server/actions/signInAction.ts b/packages/nextjs/src/server/actions/signInAction.ts index 12b78ba75..b90391e0f 100644 --- a/packages/nextjs/src/server/actions/signInAction.ts +++ b/packages/nextjs/src/server/actions/signInAction.ts @@ -27,7 +27,6 @@ import { IdToken, isEmpty, } from '@asgardeo/node'; -import {ReadonlyRequestCookies} from 'next/dist/server/web/spec-extension/adapters/request-cookies'; import {cookies} from 'next/headers'; import AsgardeoNextClient from '../../AsgardeoNextClient'; import {AsgardeoNextConfig} from '../../models/config'; @@ -35,6 +34,8 @@ import {DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS} from '../../utils/constants'; import logger from '../../utils/logger'; import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager'; +type RequestCookies = Awaited>; + /** * Server action for signing in a user. * Handles the embedded sign-in flow and manages session cookies. @@ -58,7 +59,7 @@ const signInAction = async ( }> => { try { const client: AsgardeoNextClient = AsgardeoNextClient.getInstance(); - const cookieStore: ReadonlyRequestCookies = await cookies(); + const cookieStore: RequestCookies = await cookies(); let sessionId: string | undefined; diff --git a/packages/nextjs/src/server/actions/signOutAction.ts b/packages/nextjs/src/server/actions/signOutAction.ts index 6d76e13ea..94d236f40 100644 --- a/packages/nextjs/src/server/actions/signOutAction.ts +++ b/packages/nextjs/src/server/actions/signOutAction.ts @@ -18,13 +18,14 @@ 'use server'; -import {ReadonlyRequestCookies} from 'next/dist/server/web/spec-extension/adapters/request-cookies'; import {cookies} from 'next/headers'; import getSessionId from './getSessionId'; import AsgardeoNextClient from '../../AsgardeoNextClient'; import logger from '../../utils/logger'; import SessionManager from '../../utils/SessionManager'; +type RequestCookies = Awaited>; + /** * Server action for signing out a user. * Clears both JWT and legacy session cookies. @@ -35,7 +36,7 @@ const signOutAction = async (): Promise<{data?: {afterSignOutUrl?: string}; erro logger.debug('[signOutAction] Initiating sign out process from the server action.'); const clearSessionCookies = async (): Promise => { - const cookieStore: ReadonlyRequestCookies = await cookies(); + const cookieStore: RequestCookies = await cookies(); cookieStore.delete(SessionManager.getSessionCookieName()); cookieStore.delete(SessionManager.getTempSessionCookieName()); diff --git a/packages/nextjs/src/server/actions/switchOrganization.ts b/packages/nextjs/src/server/actions/switchOrganization.ts index e01de6b1b..69db4520a 100644 --- a/packages/nextjs/src/server/actions/switchOrganization.ts +++ b/packages/nextjs/src/server/actions/switchOrganization.ts @@ -19,7 +19,6 @@ 'use server'; import {Organization, AsgardeoAPIError, IdToken, TokenResponse} from '@asgardeo/node'; -import {ReadonlyRequestCookies} from 'next/dist/server/web/spec-extension/adapters/request-cookies'; import {cookies} from 'next/headers'; import getSessionId from './getSessionId'; import AsgardeoNextClient from '../../AsgardeoNextClient'; @@ -28,6 +27,8 @@ import {DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS} from '../../utils/constants'; import logger from '../../utils/logger'; import SessionManager from '../../utils/SessionManager'; +type RequestCookies = Awaited>; + /** * Server action to switch organization. */ @@ -36,7 +37,7 @@ const switchOrganization = async ( sessionId: string | undefined, ): Promise => { try { - const cookieStore: ReadonlyRequestCookies = await cookies(); + const cookieStore: RequestCookies = await cookies(); const client: AsgardeoNextClient = AsgardeoNextClient.getInstance(); const resolvedSessionId: string = sessionId ?? ((await getSessionId()) as string); const response: TokenResponse | Response = await client.switchOrganization(organization, resolvedSessionId); diff --git a/packages/nextjs/src/utils/SessionManager.ts b/packages/nextjs/src/utils/SessionManager.ts index ebe929c79..be4a34b8d 100644 --- a/packages/nextjs/src/utils/SessionManager.ts +++ b/packages/nextjs/src/utils/SessionManager.ts @@ -38,6 +38,8 @@ export interface SessionTokenPayload extends JWTPayload { sessionId: string; /** User ID */ sub: string; + /** Token type discriminant — must be 'session' for access-session JWTs */ + type: 'session'; } /** @@ -158,6 +160,10 @@ class SessionManager { const secret: Uint8Array = this.getSecret(); const {payload} = await jwtVerify(token, secret); + if (payload['type'] !== 'session') { + throw new Error('Invalid token type'); + } + return payload as SessionTokenPayload; } catch (error) { throw new AsgardeoRuntimeError( diff --git a/packages/nextjs/src/utils/constants.ts b/packages/nextjs/src/utils/constants.ts index 3f939b28b..bab0a7116 100644 --- a/packages/nextjs/src/utils/constants.ts +++ b/packages/nextjs/src/utils/constants.ts @@ -21,7 +21,7 @@ * refreshes the token. A 60-second buffer prevents races where the token is * valid when a request starts but expires mid-flight. */ -export const REFRESH_BUFFER_SECONDS: number = 60; +export const REFRESH_BUFFER_SECONDS: number = 25; /** * Default access token lifetime in seconds (1 hour). From ce75e4a349effb628d1d87c4744bd99416a53d0c Mon Sep 17 00:00:00 2001 From: kavindadewmith Date: Mon, 20 Apr 2026 11:36:39 +0530 Subject: [PATCH 04/12] Enhance session management by implementing verifySessionTokenForRefresh method and updating middleware to use it for token verification --- .../nextjs/src/server/actions/refreshToken.ts | 2 +- .../server/middleware/asgardeoMiddleware.ts | 14 +++--- packages/nextjs/src/utils/SessionManager.ts | 43 ++++++++++++++----- 3 files changed, 43 insertions(+), 16 deletions(-) diff --git a/packages/nextjs/src/server/actions/refreshToken.ts b/packages/nextjs/src/server/actions/refreshToken.ts index 748ab66d3..aa7c59d7d 100644 --- a/packages/nextjs/src/server/actions/refreshToken.ts +++ b/packages/nextjs/src/server/actions/refreshToken.ts @@ -52,7 +52,7 @@ const refreshToken = async (): Promise => { ); } - const sessionPayload: SessionTokenPayload = await SessionManager.verifySessionToken(sessionToken); + const sessionPayload: SessionTokenPayload = await SessionManager.verifySessionTokenForRefresh(sessionToken); const client: AsgardeoNextClient = AsgardeoNextClient.getInstance(); const config: AsgardeoNextConfig = await (client.getConfiguration() as unknown as Promise); diff --git a/packages/nextjs/src/server/middleware/asgardeoMiddleware.ts b/packages/nextjs/src/server/middleware/asgardeoMiddleware.ts index c5520b493..1b01ce460 100644 --- a/packages/nextjs/src/server/middleware/asgardeoMiddleware.ts +++ b/packages/nextjs/src/server/middleware/asgardeoMiddleware.ts @@ -174,20 +174,24 @@ const asgardeoMiddleware = // Step 1: Attempt to get a fully verified (signature + expiry) session. const verifiedSession: SessionTokenPayload | undefined = await getSessionFromRequest(request); - // Step 2: If no verified session exists, try to decode the raw cookie without - // expiry verification. This allows the middleware to recover from an expired - // access token as long as a refresh token is still present. + // Step 2: If no verified session exists, verify the raw cookie's signature + // without enforcing expiry. This allows the middleware to recover from an + // expired access token as long as the JWT is authentic and a refresh token + // is present. Skipping the signature check here would let a tampered cookie + // drive identity-confusion attacks since tokenRefreshCore reuses `sub`, + // `sessionId`, and `organizationId` from the input payload when minting the + // new session JWT. let expiredSession: SessionTokenPayload | undefined; if (!verifiedSession) { const rawToken: string | undefined = request.cookies.get(SessionManager.getSessionCookieName())?.value; if (rawToken) { try { - const decoded: SessionTokenPayload = SessionManager.decodeSessionToken(rawToken); + const decoded: SessionTokenPayload = await SessionManager.verifySessionTokenForRefresh(rawToken); if (decoded.refreshToken) { expiredSession = decoded; } } catch { - // Malformed token — ignore. + // Forged, tampered, wrong type, or malformed — ignore. } } } diff --git a/packages/nextjs/src/utils/SessionManager.ts b/packages/nextjs/src/utils/SessionManager.ts index be4a34b8d..1d576772e 100644 --- a/packages/nextjs/src/utils/SessionManager.ts +++ b/packages/nextjs/src/utils/SessionManager.ts @@ -17,7 +17,7 @@ */ import {AsgardeoRuntimeError, CookieConfig} from '@asgardeo/node'; -import {SignJWT, jwtVerify, decodeJwt, JWTPayload} from 'jose'; +import {SignJWT, jwtVerify, compactVerify, JWTPayload} from 'jose'; import {DEFAULT_SESSION_EXPIRY_SECONDS} from './constants'; /** @@ -143,15 +143,6 @@ class SessionManager { return jwt; } - /** - * Decode a session token without verifying the signature or expiry. - * Use only to inspect an expired token (e.g. to extract a refresh token for renewal). - * Never use the result to make authorization decisions. - */ - static decodeSessionToken(token: string): SessionTokenPayload { - return decodeJwt(token) as SessionTokenPayload; - } - /** * Verify and decode a session token */ @@ -175,6 +166,38 @@ class SessionManager { } } + /** + * Verify a session token for refresh. Validates the HMAC signature and the + * `type === 'session'` discriminant but intentionally skips the `exp` check + * so an expired access token can still be exchanged for a new one. + * + * Session lifetime is still bounded — the cookie's `maxAge` is set from + * `sessionExpirySeconds`, so the browser drops an over-age session regardless + * of the access-token exp embedded in the JWT. + * + * Never use the returned payload for authorization. + */ + static async verifySessionTokenForRefresh(token: string): Promise { + try { + const secret: Uint8Array = this.getSecret(); + const {payload: rawPayload} = await compactVerify(token, secret); + const payload: SessionTokenPayload = JSON.parse(new TextDecoder().decode(rawPayload)) as SessionTokenPayload; + + if (payload.type !== 'session') { + throw new Error('Invalid token type'); + } + + return payload; + } catch (error) { + throw new AsgardeoRuntimeError( + `Invalid session token: ${error instanceof Error ? error.message : 'Unknown error'}`, + 'invalid-session-token-for-refresh', + 'nextjs', + 'Session token signature or type check failed during refresh', + ); + } + } + /** * Verify and decode a temporary session token */ From 8533a692ee56792dc9638cc967d5cc91a21018c5 Mon Sep 17 00:00:00 2001 From: kavindadewmith Date: Mon, 20 Apr 2026 11:39:18 +0530 Subject: [PATCH 05/12] Fix middleware exports in package.json for edge-light and types --- packages/nextjs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 713e5ccd5..cd85c372d 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -30,8 +30,8 @@ "require": "./dist/cjs/server/index.js" }, "./middleware": { - "edge-light": "./dist/esm/middleware.js", "types": "./dist/types/middleware.d.ts", + "edge-light": "./dist/esm/middleware.js", "import": "./dist/esm/middleware.js", "require": "./dist/cjs/middleware.js" } From 399cc5db2785b8dfb994b6fa61357dadb5a9c998 Mon Sep 17 00:00:00 2001 From: kavindadewmith Date: Mon, 20 Apr 2026 12:25:27 +0530 Subject: [PATCH 06/12] Update refresh token handling to use RefreshResult type and improve session expiry logic --- .../contexts/Asgardeo/AsgardeoContext.ts | 6 ++--- .../contexts/Asgardeo/AsgardeoProvider.tsx | 3 ++- .../nextjs/src/server/actions/refreshToken.ts | 27 ++++++++++++++++--- .../src/server/actions/switchOrganization.ts | 6 ++--- packages/nextjs/src/utils/constants.ts | 13 +++++++-- 5 files changed, 43 insertions(+), 12 deletions(-) diff --git a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts index 4eae24e0f..65cbd1a33 100644 --- a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts +++ b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts @@ -18,16 +18,16 @@ 'use client'; -import {TokenResponse} from '@asgardeo/node'; import {AsgardeoContextProps as AsgardeoReactContextProps} from '@asgardeo/react'; import {Context, createContext} from 'react'; +import {RefreshResult} from '../../../server/actions/refreshToken'; /** * Props interface of {@link AsgardeoContext} */ export type AsgardeoContextProps = Partial & { clearSession?: () => Promise; - refreshToken?: () => Promise; + refreshToken?: () => Promise; }; /** @@ -42,7 +42,7 @@ const AsgardeoContext: Context = createContext Promise.resolve({} as TokenResponse), + refreshToken: () => Promise.resolve({expiresAt: 0}), signIn: () => Promise.resolve({} as any), signInUrl: undefined, signOut: () => Promise.resolve({} as any), diff --git a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx index b340d37db..9e5a09cdf 100644 --- a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx +++ b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx @@ -33,6 +33,7 @@ import { CreateOrganizationPayload, AsgardeoRuntimeError, } from '@asgardeo/node'; +import {RefreshResult} from '../../../server/actions/refreshToken'; import { I18nProvider, FlowProvider, @@ -69,7 +70,7 @@ export type AsgardeoClientProviderProps = Partial Promise; + refreshToken: () => Promise; revalidateMyOrganizations?: (sessionId?: string) => Promise; signIn: AsgardeoContextProps['signIn']; signOut: AsgardeoContextProps['signOut']; diff --git a/packages/nextjs/src/server/actions/refreshToken.ts b/packages/nextjs/src/server/actions/refreshToken.ts index aa7c59d7d..cf81beac6 100644 --- a/packages/nextjs/src/server/actions/refreshToken.ts +++ b/packages/nextjs/src/server/actions/refreshToken.ts @@ -18,15 +18,31 @@ 'use server'; -import {AsgardeoAPIError, logger, TokenResponse} from '@asgardeo/node'; +import {AsgardeoAPIError, logger} from '@asgardeo/node'; import {cookies} from 'next/headers'; import AsgardeoNextClient from '../../AsgardeoNextClient'; import {AsgardeoNextConfig} from '../../models/config'; +import {DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS} from '../../utils/constants'; import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager'; import tokenRefreshCore, {TokenRefreshCoreResult} from '../../utils/tokenRefreshCore'; type RequestCookies = Awaited>; +/** + * Client-safe result of a token refresh. + * + * Intentionally omits accessToken, refreshToken, idToken, and scopes — those stay + * server-side in the HttpOnly session cookie. Returning tokens from a Server Action + * serializes them into browser memory, defeating the HttpOnly boundary and exposing + * them to XSS, browser extensions, and error-tracking SDKs. + * + * `expiresAt` is epoch seconds for the new access token; the client uses it to + * schedule the next refresh. + */ +export interface RefreshResult { + expiresAt: number; +} + /** * 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. @@ -38,7 +54,7 @@ type RequestCookies = Awaited>; * 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 (): Promise => { try { const cookieStore: RequestCookies = await cookies(); const sessionToken: string | undefined = cookieStore.get(SessionManager.getSessionCookieName())?.value; @@ -76,8 +92,13 @@ const refreshToken = async (): Promise => { logger.warn('[refreshToken] Could not write session cookie — called from SSR rendering context.'); } + const expiresInSeconds: number = result.tokenResponse.expiresIn + ? parseInt(result.tokenResponse.expiresIn, 10) + : DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS; + const expiresAt: number = Math.floor(Date.now() / 1000) + expiresInSeconds; + logger.debug('[refreshToken] Token refresh succeeded.'); - return result.tokenResponse; + return {expiresAt}; } catch (error) { // Clear the dead session cookie before throwing so the browser is not left // holding a stale credential. This is best-effort — if called from an SSR diff --git a/packages/nextjs/src/server/actions/switchOrganization.ts b/packages/nextjs/src/server/actions/switchOrganization.ts index 69db4520a..b25db68f9 100644 --- a/packages/nextjs/src/server/actions/switchOrganization.ts +++ b/packages/nextjs/src/server/actions/switchOrganization.ts @@ -54,9 +54,9 @@ const switchOrganization = async ( | undefined; const config: AsgardeoNextConfig = client.getConfiguration() as AsgardeoNextConfig; const sessionExpirySeconds: number = SessionManager.resolveSessionExpiry(config.sessionExpirySeconds); - const expiresIn: number = tokenResponse.expiresIn - ? parseInt(tokenResponse.expiresIn, 10) - : DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS; + const parsedExpiresIn: number = tokenResponse.expiresIn ? parseInt(tokenResponse.expiresIn, 10) : NaN; + const expiresIn: number = + Number.isFinite(parsedExpiresIn) && parsedExpiresIn > 0 ? parsedExpiresIn : DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS; const sessionToken: string = await SessionManager.createSessionToken( tokenResponse.accessToken, diff --git a/packages/nextjs/src/utils/constants.ts b/packages/nextjs/src/utils/constants.ts index bab0a7116..c7a2f78e8 100644 --- a/packages/nextjs/src/utils/constants.ts +++ b/packages/nextjs/src/utils/constants.ts @@ -41,7 +41,16 @@ export const DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS: number = 3600; * (e.g. `ASGARDEO_SESSION_EXPIRY_SECONDS=86400`). * 3. This constant — applied when neither of the above is present. * - * The JWT `exp` claim and the browser cookie `maxAge` are always set to the - * same resolved value so they expire together. + * Two independent expiry bounds apply to the session and they are generally + * NOT the same value: + * + * - JWT `exp` claim — set by `SessionManager.createSessionToken(...)` from + * the `accessTokenTtlSeconds` argument (i.e. the access token's `expires_in` + * returned by the auth server, typically ~1 hour). This controls when + * `verifySessionToken` rejects the token and is the trigger for a refresh. + * - Browser cookie `maxAge` — set by the caller (sign-in / refresh / org-switch + * actions) from `SessionManager.resolveSessionExpiry(...)`, which returns + * this constant by default (24 hours). This controls how long the browser + * holds the cookie before discarding it. */ export const DEFAULT_SESSION_EXPIRY_SECONDS: number = 86400; From 4aed363f3c5c06222df96ce879898e9359a27bb2 Mon Sep 17 00:00:00 2001 From: kavindadewmith Date: Mon, 20 Apr 2026 19:31:53 +0530 Subject: [PATCH 07/12] Add session cookie expiry time configuration and handle refresh token logic - Introduced session cookie expiry time in seconds to AsgardeoNodeConfig and AsgardeoNextConfig. - Implemented handleRefreshToken utility for managing OAuth refresh token logic. - Updated session management actions to utilize session cookie expiry time. - Refactored related actions and middleware to ensure consistent handling of session cookies. --- .../sessionConstants.ts} | 24 +++++-------- packages/nextjs/src/models/config.ts | 17 +--------- .../nextjs/src/server/AsgardeoProvider.tsx | 34 +++++++++++++------ .../actions/handleOAuthCallbackAction.ts | 10 +++--- .../nextjs/src/server/actions/refreshToken.ts | 15 ++++---- .../nextjs/src/server/actions/signInAction.ts | 10 +++--- .../src/server/actions/switchOrganization.ts | 11 +++--- .../server/middleware/asgardeoMiddleware.ts | 12 +++---- packages/nextjs/src/utils/SessionManager.ts | 18 +++++----- .../src/utils/decorateConfigWithNextEnv.ts | 6 ++++ ...enRefreshCore.ts => handleRefreshToken.ts} | 30 ++++++++-------- packages/node/src/models/config.ts | 17 +++++++++- 12 files changed, 103 insertions(+), 101 deletions(-) rename packages/nextjs/src/{utils/constants.ts => constants/sessionConstants.ts} (66%) rename packages/nextjs/src/utils/{tokenRefreshCore.ts => handleRefreshToken.ts} (80%) diff --git a/packages/nextjs/src/utils/constants.ts b/packages/nextjs/src/constants/sessionConstants.ts similarity index 66% rename from packages/nextjs/src/utils/constants.ts rename to packages/nextjs/src/constants/sessionConstants.ts index c7a2f78e8..4f5eb65ad 100644 --- a/packages/nextjs/src/utils/constants.ts +++ b/packages/nextjs/src/constants/sessionConstants.ts @@ -18,27 +18,21 @@ /** * Number of seconds before access token expiry at which the SDK proactively - * refreshes the token. A 60-second buffer prevents races where the token is + * refreshes the token. A 25-second buffer prevents races where the token is * valid when a request starts but expires mid-flight. */ export const REFRESH_BUFFER_SECONDS: number = 25; /** - * Default access token lifetime in seconds (1 hour). - * Used as a fallback when the token endpoint does not return an `expires_in` value. - */ -export const DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS: number = 3600; - -/** - * Default session lifetime in seconds (24 hours). + * Default session cookie lifetime in seconds (24 hours). * - * Used when no explicit session expiry is configured. The session lifetime - * can be overridden in two ways (evaluated in this order): + * Used when no explicit session cookie expiry is configured. The session cookie + * lifetime can be overridden in two ways (evaluated in this order): * - * 1. `sessionExpirySeconds` in `AsgardeoNextConfig` — set programmatically + * 1. `sessionCookieExpiryTime` in `AsgardeoNodeConfig` — set programmatically * when initialising the SDK. - * 2. `ASGARDEO_SESSION_EXPIRY_SECONDS` environment variable — set in `.env` - * (e.g. `ASGARDEO_SESSION_EXPIRY_SECONDS=86400`). + * 2. `ASGARDEO_SESSION_COOKIE_EXPIRY_TIME` environment variable — set in `.env` + * (e.g. `ASGARDEO_SESSION_COOKIE_EXPIRY_TIME=86400`). * 3. This constant — applied when neither of the above is present. * * Two independent expiry bounds apply to the session and they are generally @@ -49,8 +43,8 @@ export const DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS: number = 3600; * returned by the auth server, typically ~1 hour). This controls when * `verifySessionToken` rejects the token and is the trigger for a refresh. * - Browser cookie `maxAge` — set by the caller (sign-in / refresh / org-switch - * actions) from `SessionManager.resolveSessionExpiry(...)`, which returns + * actions) from `SessionManager.resolveSessionCookieExpiry(...)`, which returns * this constant by default (24 hours). This controls how long the browser * holds the cookie before discarding it. */ -export const DEFAULT_SESSION_EXPIRY_SECONDS: number = 86400; +export const DEFAULT_SESSION_COOKIE_EXPIRY_TIME: number = 86400; diff --git a/packages/nextjs/src/models/config.ts b/packages/nextjs/src/models/config.ts index 617e29c59..b36602f8b 100644 --- a/packages/nextjs/src/models/config.ts +++ b/packages/nextjs/src/models/config.ts @@ -29,19 +29,4 @@ import {AsgardeoNodeConfig} from '@asgardeo/node'; * - Session configuration for Next.js apps * - Environment variable integration */ -export type AsgardeoNextConfig = AsgardeoNodeConfig & { - /** - * Session lifetime in seconds. Determines how long the session JWT and its - * corresponding cookie remain valid after sign-in. - * - * Resolution order (first defined value wins): - * 1. This field — set programmatically at SDK initialisation. - * 2. `ASGARDEO_SESSION_EXPIRY_SECONDS` environment variable. - * 3. Built-in default of 86400 seconds (24 hours). - * - * @example - * // 8-hour session - * { sessionExpirySeconds: 28800 } - */ - sessionExpirySeconds?: number; -}; +export type AsgardeoNextConfig = AsgardeoNodeConfig; diff --git a/packages/nextjs/src/server/AsgardeoProvider.tsx b/packages/nextjs/src/server/AsgardeoProvider.tsx index 613ffe25d..0c9f59759 100644 --- a/packages/nextjs/src/server/AsgardeoProvider.tsx +++ b/packages/nextjs/src/server/AsgardeoProvider.tsx @@ -18,9 +18,9 @@ 'use server'; -import {BrandingPreference, AsgardeoRuntimeError, IdToken, Organization, User, UserProfile} from '@asgardeo/node'; -import {AsgardeoProviderProps} from '@asgardeo/react'; -import {FC, PropsWithChildren, ReactElement} from 'react'; +import { BrandingPreference, AsgardeoRuntimeError, IdToken, Organization, User, UserProfile } from '@asgardeo/node'; +import { AsgardeoProviderProps } from '@asgardeo/react'; +import { FC, PropsWithChildren, ReactElement } from 'react'; import clearSession from './actions/clearSession'; import createOrganization from './actions/createOrganization'; import getAllOrganizations from './actions/getAllOrganizations'; @@ -41,15 +41,29 @@ import switchOrganization from './actions/switchOrganization'; import updateUserProfileAction from './actions/updateUserProfileAction'; import AsgardeoNextClient from '../AsgardeoNextClient'; import AsgardeoClientProvider from '../client/contexts/Asgardeo/AsgardeoProvider.js'; -import {AsgardeoNextConfig} from '../models/config'; +import { AsgardeoNextConfig } from '../models/config'; import logger from '../utils/logger'; -import {SessionTokenPayload} from '../utils/SessionManager'; +import { SessionTokenPayload } from '../utils/SessionManager'; /** * Props interface of {@link AsgardeoServerProvider} */ export type AsgardeoServerProviderProps = Partial & { clientSecret?: string; + /** + * Session cookie lifetime in seconds. Determines how long the session cookie + * remains valid in the browser after sign-in. + * + * Resolution order (first defined value wins): + * 1. This prop — set here when mounting the provider. + * 2. `ASGARDEO_SESSION_COOKIE_EXPIRY_TIME` environment variable. + * 3. Built-in default of 86400 seconds (24 hours). + * + * @example + * // 8-hour session cookie + * + */ + sessionCookieExpiryTime?: number; }; /** @@ -122,13 +136,13 @@ const AsgardeoServerProvider: FC> if (sessionPayload?.organizationId) { updatedBaseUrl = `${config?.baseUrl}/o`; - config = {...config, baseUrl: updatedBaseUrl}; + config = { ...config, baseUrl: updatedBaseUrl }; } else if (sessionId) { try { const idToken: IdToken = await asgardeoClient.getDecodedIdToken(sessionId); if (idToken?.['user_org']) { updatedBaseUrl = `${config?.baseUrl}/o`; - config = {...config, baseUrl: updatedBaseUrl}; + config = { ...config, baseUrl: updatedBaseUrl }; } } catch { // Continue without organization info @@ -143,12 +157,12 @@ const AsgardeoServerProvider: FC> if (shouldFetchUserProfile) { try { const userResponse: { - data: {user: User | null}; + data: { user: User | null }; error: string | null; success: boolean; } = await getUserAction(sessionId); const userProfileResponse: { - data: {userProfile: UserProfile}; + data: { userProfile: UserProfile }; error: string | null; success: boolean; } = await getUserProfileAction(sessionId); @@ -163,7 +177,7 @@ const AsgardeoServerProvider: FC> if (shouldFetchOrganizations) { try { const currentOrganizationResponse: { - data: {organization?: Organization; user?: Record}; + data: { organization?: Organization; user?: Record }; error: string | null; success: boolean; } = await getCurrentOrganizationAction(sessionId); diff --git a/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts b/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts index 8901629ba..2e17eef90 100644 --- a/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts +++ b/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts @@ -22,7 +22,6 @@ import {IdToken} from '@asgardeo/node'; import {cookies} from 'next/headers'; import AsgardeoNextClient from '../../AsgardeoNextClient'; import {AsgardeoNextConfig} from '../../models/config'; -import {DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS} from '../../utils/constants'; import logger from '../../utils/logger'; import SessionManager from '../../utils/SessionManager'; @@ -100,7 +99,7 @@ const handleOAuthCallbackAction = async ( sessionId, ); - const config: AsgardeoNextConfig = asgardeoClient.getConfiguration() as AsgardeoNextConfig; + const config: AsgardeoNextConfig = await (asgardeoClient.getConfiguration() as unknown as Promise); if (signInResult) { try { @@ -115,9 +114,8 @@ const handleOAuthCallbackAction = async ( const organizationId: string | undefined = (idToken['user_org'] || idToken['organization_id']) as | string | undefined; - const expiresIn: number = - (signInResult['expiresIn'] as number | undefined) ?? DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS; - const sessionExpirySeconds: number = SessionManager.resolveSessionExpiry(config.sessionExpirySeconds); + const expiresIn: number = signInResult['expiresIn'] as number; + const sessionCookieExpiryTime: number = SessionManager.resolveSessionCookieExpiry(config.sessionCookieExpiryTime); const sessionToken: string = await SessionManager.createSessionToken( accessToken, @@ -132,7 +130,7 @@ const handleOAuthCallbackAction = async ( cookieStore.set( SessionManager.getSessionCookieName(), sessionToken, - SessionManager.getSessionCookieOptions(sessionExpirySeconds), + SessionManager.getSessionCookieOptions(sessionCookieExpiryTime), ); cookieStore.delete(SessionManager.getTempSessionCookieName()); diff --git a/packages/nextjs/src/server/actions/refreshToken.ts b/packages/nextjs/src/server/actions/refreshToken.ts index cf81beac6..082a2eed7 100644 --- a/packages/nextjs/src/server/actions/refreshToken.ts +++ b/packages/nextjs/src/server/actions/refreshToken.ts @@ -22,9 +22,8 @@ import {AsgardeoAPIError, logger} from '@asgardeo/node'; import {cookies} from 'next/headers'; import AsgardeoNextClient from '../../AsgardeoNextClient'; import {AsgardeoNextConfig} from '../../models/config'; -import {DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS} from '../../utils/constants'; import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager'; -import tokenRefreshCore, {TokenRefreshCoreResult} from '../../utils/tokenRefreshCore'; +import handleRefreshToken, {HandleRefreshTokenResult} from '../../utils/handleRefreshToken'; type RequestCookies = Awaited>; @@ -47,7 +46,7 @@ export interface RefreshResult { * 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. * - * Delegates the HTTP exchange to tokenRefreshCore so the same logic is shared + * 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 @@ -72,18 +71,18 @@ const refreshToken = async (): Promise => { const client: AsgardeoNextClient = AsgardeoNextClient.getInstance(); const config: AsgardeoNextConfig = await (client.getConfiguration() as unknown as Promise); - const result: TokenRefreshCoreResult = await tokenRefreshCore(sessionPayload, { + const result: HandleRefreshTokenResult = await handleRefreshToken(sessionPayload, { baseUrl: config.baseUrl ?? '', clientId: config.clientId ?? '', clientSecret: config.clientSecret ?? '', - sessionExpirySeconds: config.sessionExpirySeconds, + sessionCookieExpiryTime: config.sessionCookieExpiryTime, }); try { cookieStore.set( SessionManager.getSessionCookieName(), result.newSessionToken, - SessionManager.getSessionCookieOptions(result.sessionExpirySeconds), + SessionManager.getSessionCookieOptions(result.sessionCookieExpiryTime), ); } catch { // cookies().set() is only permitted inside a Server Action invoked from the client @@ -92,9 +91,7 @@ const refreshToken = async (): Promise => { logger.warn('[refreshToken] Could not write session cookie — called from SSR rendering context.'); } - const expiresInSeconds: number = result.tokenResponse.expiresIn - ? parseInt(result.tokenResponse.expiresIn, 10) - : DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS; + const expiresInSeconds: number = parseInt(result.tokenResponse.expiresIn, 10); const expiresAt: number = Math.floor(Date.now() / 1000) + expiresInSeconds; logger.debug('[refreshToken] Token refresh succeeded.'); diff --git a/packages/nextjs/src/server/actions/signInAction.ts b/packages/nextjs/src/server/actions/signInAction.ts index b90391e0f..aaab410ec 100644 --- a/packages/nextjs/src/server/actions/signInAction.ts +++ b/packages/nextjs/src/server/actions/signInAction.ts @@ -30,7 +30,6 @@ import { import {cookies} from 'next/headers'; import AsgardeoNextClient from '../../AsgardeoNextClient'; import {AsgardeoNextConfig} from '../../models/config'; -import {DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS} from '../../utils/constants'; import logger from '../../utils/logger'; import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager'; @@ -131,10 +130,9 @@ const signInAction = async ( const organizationId: string | undefined = (idToken['user_org'] || idToken['organization_id']) as | string | undefined; - const expiresIn: number = - (signInResult['expiresIn'] as number | undefined) ?? DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS; - const config: AsgardeoNextConfig = client.getConfiguration() as AsgardeoNextConfig; - const sessionExpirySeconds: number = SessionManager.resolveSessionExpiry(config.sessionExpirySeconds); + const expiresIn: number = signInResult['expiresIn'] as number; + const config: AsgardeoNextConfig = await (client.getConfiguration() as unknown as Promise); + const sessionCookieExpiryTime: number = SessionManager.resolveSessionCookieExpiry(config.sessionCookieExpiryTime); const sessionToken: string = await SessionManager.createSessionToken( accessToken, @@ -149,7 +147,7 @@ const signInAction = async ( cookieStore.set( SessionManager.getSessionCookieName(), sessionToken, - SessionManager.getSessionCookieOptions(sessionExpirySeconds), + SessionManager.getSessionCookieOptions(sessionCookieExpiryTime), ); cookieStore.delete(SessionManager.getTempSessionCookieName()); diff --git a/packages/nextjs/src/server/actions/switchOrganization.ts b/packages/nextjs/src/server/actions/switchOrganization.ts index b25db68f9..37dc2b657 100644 --- a/packages/nextjs/src/server/actions/switchOrganization.ts +++ b/packages/nextjs/src/server/actions/switchOrganization.ts @@ -23,7 +23,6 @@ import {cookies} from 'next/headers'; import getSessionId from './getSessionId'; import AsgardeoNextClient from '../../AsgardeoNextClient'; import {AsgardeoNextConfig} from '../../models/config'; -import {DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS} from '../../utils/constants'; import logger from '../../utils/logger'; import SessionManager from '../../utils/SessionManager'; @@ -52,11 +51,9 @@ const switchOrganization = async ( const organizationId: string | undefined = (idToken['user_org'] || idToken['organization_id']) as | string | undefined; - const config: AsgardeoNextConfig = client.getConfiguration() as AsgardeoNextConfig; - const sessionExpirySeconds: number = SessionManager.resolveSessionExpiry(config.sessionExpirySeconds); - const parsedExpiresIn: number = tokenResponse.expiresIn ? parseInt(tokenResponse.expiresIn, 10) : NaN; - const expiresIn: number = - Number.isFinite(parsedExpiresIn) && parsedExpiresIn > 0 ? parsedExpiresIn : DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS; + const config: AsgardeoNextConfig = await (client.getConfiguration() as unknown as Promise); + const sessionCookieExpiryTime: number = SessionManager.resolveSessionCookieExpiry(config.sessionCookieExpiryTime); + const expiresIn: number = parseInt(tokenResponse.expiresIn, 10); const sessionToken: string = await SessionManager.createSessionToken( tokenResponse.accessToken, @@ -73,7 +70,7 @@ const switchOrganization = async ( cookieStore.set( SessionManager.getSessionCookieName(), sessionToken, - SessionManager.getSessionCookieOptions(sessionExpirySeconds), + SessionManager.getSessionCookieOptions(sessionCookieExpiryTime), ); } diff --git a/packages/nextjs/src/server/middleware/asgardeoMiddleware.ts b/packages/nextjs/src/server/middleware/asgardeoMiddleware.ts index 1b01ce460..97d53663c 100644 --- a/packages/nextjs/src/server/middleware/asgardeoMiddleware.ts +++ b/packages/nextjs/src/server/middleware/asgardeoMiddleware.ts @@ -18,11 +18,11 @@ import {NextRequest, NextResponse} from 'next/server'; import {AsgardeoNextConfig} from '../../models/config'; -import {REFRESH_BUFFER_SECONDS} from '../../utils/constants'; +import {REFRESH_BUFFER_SECONDS} from '../../constants/sessionConstants'; import decorateConfigWithNextEnv from '../../utils/decorateConfigWithNextEnv'; import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager'; import {getSessionFromRequest, getSessionIdFromRequest} from '../../utils/sessionUtils'; -import tokenRefreshCore from '../../utils/tokenRefreshCore'; +import handleRefreshToken from '../../utils/handleRefreshToken'; export type AsgardeoMiddlewareOptions = Partial; @@ -178,7 +178,7 @@ const asgardeoMiddleware = // without enforcing expiry. This allows the middleware to recover from an // expired access token as long as the JWT is authentic and a refresh token // is present. Skipping the signature check here would let a tampered cookie - // drive identity-confusion attacks since tokenRefreshCore reuses `sub`, + // drive identity-confusion attacks since handleRefreshToken reuses `sub`, // `sessionId`, and `organizationId` from the input payload when minting the // new session JWT. let expiredSession: SessionTokenPayload | undefined; @@ -221,15 +221,15 @@ const asgardeoMiddleware = if (needsRefresh && candidateSession) { try { - const {newSessionToken, sessionExpirySeconds} = await tokenRefreshCore(candidateSession, { + const {newSessionToken, sessionCookieExpiryTime} = await handleRefreshToken(candidateSession, { baseUrl: resolvedConfig.baseUrl!, clientId: resolvedConfig.clientId!, clientSecret: resolvedConfig.clientSecret!, - sessionExpirySeconds: resolvedConfig.sessionExpirySeconds, + sessionCookieExpiryTime: resolvedConfig.sessionCookieExpiryTime, }); // Verify the newly minted token so activeSession reflects fresh claims. activeSession = await SessionManager.verifySessionToken(newSessionToken); - refreshCookieUpdate = {expiry: sessionExpirySeconds, token: newSessionToken}; + refreshCookieUpdate = {expiry: sessionCookieExpiryTime, token: newSessionToken}; } catch { // Refresh failed — clear the irrecoverable session. activeSession = undefined; diff --git a/packages/nextjs/src/utils/SessionManager.ts b/packages/nextjs/src/utils/SessionManager.ts index 1d576772e..ba89cd229 100644 --- a/packages/nextjs/src/utils/SessionManager.ts +++ b/packages/nextjs/src/utils/SessionManager.ts @@ -18,7 +18,7 @@ import {AsgardeoRuntimeError, CookieConfig} from '@asgardeo/node'; import {SignJWT, jwtVerify, compactVerify, JWTPayload} from 'jose'; -import {DEFAULT_SESSION_EXPIRY_SECONDS} from './constants'; +import {DEFAULT_SESSION_COOKIE_EXPIRY_TIME} from '../constants/sessionConstants'; /** * Session token payload interface @@ -90,19 +90,19 @@ class SessionManager { } /** - * Resolve the session expiry in seconds. + * Resolve the session cookie expiry time in seconds. * * Resolution order (first defined value wins): - * 1. `configuredExpiry` — value from `AsgardeoNextConfig.sessionExpirySeconds` - * 2. `ASGARDEO_SESSION_EXPIRY_SECONDS` environment variable - * 3. `DEFAULT_SESSION_EXPIRY_SECONDS` (24 hours) + * 1. `configuredExpiry` — value from `AsgardeoNodeConfig.sessionCookieExpiryTime` + * 2. `ASGARDEO_SESSION_COOKIE_EXPIRY_TIME` environment variable + * 3. `DEFAULT_SESSION_COOKIE_EXPIRY_TIME` (24 hours) */ - static resolveSessionExpiry(configuredExpiry?: number): number { + static resolveSessionCookieExpiry(configuredExpiry?: number): number { if (configuredExpiry != null && configuredExpiry > 0) { return configuredExpiry; } - const envValue: string | undefined = process.env['ASGARDEO_SESSION_EXPIRY_SECONDS']; + const envValue: string | undefined = process.env['ASGARDEO_SESSION_COOKIE_EXPIRY_TIME']; if (envValue) { const parsed: number = parseInt(envValue, 10); @@ -112,7 +112,7 @@ class SessionManager { } } - return DEFAULT_SESSION_EXPIRY_SECONDS; + return DEFAULT_SESSION_COOKIE_EXPIRY_TIME; } static async createSessionToken( @@ -172,7 +172,7 @@ class SessionManager { * so an expired access token can still be exchanged for a new one. * * Session lifetime is still bounded — the cookie's `maxAge` is set from - * `sessionExpirySeconds`, so the browser drops an over-age session regardless + * `sessionCookieExpiryTime`, so the browser drops an over-age session regardless * of the access-token exp embedded in the JWT. * * Never use the returned payload for authorization. diff --git a/packages/nextjs/src/utils/decorateConfigWithNextEnv.ts b/packages/nextjs/src/utils/decorateConfigWithNextEnv.ts index 528898704..74036ed69 100644 --- a/packages/nextjs/src/utils/decorateConfigWithNextEnv.ts +++ b/packages/nextjs/src/utils/decorateConfigWithNextEnv.ts @@ -30,6 +30,7 @@ const decorateConfigWithNextEnv = (config: AsgardeoNextConfig): AsgardeoNextConf signUpUrl, afterSignInUrl, afterSignOutUrl, + sessionCookieExpiryTime, ...rest } = config; @@ -43,6 +44,11 @@ const decorateConfigWithNextEnv = (config: AsgardeoNextConfig): AsgardeoNextConf clientSecret: clientSecret || (process.env['ASGARDEO_CLIENT_SECRET'] as string), organizationHandle: organizationHandle || (process.env['NEXT_PUBLIC_ASGARDEO_ORGANIZATION_HANDLE'] as string), scopes: scopes || (process.env['NEXT_PUBLIC_ASGARDEO_SCOPES'] as string), + sessionCookieExpiryTime: + sessionCookieExpiryTime || + (process.env['ASGARDEO_SESSION_COOKIE_EXPIRY_TIME'] + ? parseInt(process.env['ASGARDEO_SESSION_COOKIE_EXPIRY_TIME'], 10) + : undefined), signInUrl: signInUrl || (process.env['NEXT_PUBLIC_ASGARDEO_SIGN_IN_URL'] as string), signUpUrl: signUpUrl || (process.env['NEXT_PUBLIC_ASGARDEO_SIGN_UP_URL'] as string), }; diff --git a/packages/nextjs/src/utils/tokenRefreshCore.ts b/packages/nextjs/src/utils/handleRefreshToken.ts similarity index 80% rename from packages/nextjs/src/utils/tokenRefreshCore.ts rename to packages/nextjs/src/utils/handleRefreshToken.ts index 10fe7b7d5..e0a6c9368 100644 --- a/packages/nextjs/src/utils/tokenRefreshCore.ts +++ b/packages/nextjs/src/utils/handleRefreshToken.ts @@ -17,42 +17,40 @@ */ import type {TokenResponse} from '@asgardeo/node'; -import {DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS} from './constants'; import SessionManager, {SessionTokenPayload} from './SessionManager'; /** * Config required to call the token endpoint. */ -export interface TokenRefreshCoreConfig { +export interface HandleRefreshTokenConfig { baseUrl: string; clientId: string; clientSecret: string; - sessionExpirySeconds?: number; + sessionCookieExpiryTime?: number; } /** - * Result returned by tokenRefreshCore. + * Result returned by handleRefreshToken. * Callers are responsible for persisting newSessionToken in the appropriate cookie context. */ -export interface TokenRefreshCoreResult { +export interface HandleRefreshTokenResult { newSessionToken: string; - sessionExpirySeconds: number; + sessionCookieExpiryTime: number; tokenResponse: TokenResponse; } /** - * Core token refresh logic — performs the OAuth refresh_token grant and builds a new - * session JWT string. + * Handles the OAuth refresh_token grant and builds a new session JWT string. * * Intentionally decoupled from cookie APIs so it can be called from both the Edge * Runtime (Next.js middleware) and the Node.js Runtime (server actions). * Cookie persistence is the caller's responsibility. */ -const tokenRefreshCore = async ( +const handleRefreshToken = async ( sessionPayload: SessionTokenPayload, - config: TokenRefreshCoreConfig, -): Promise => { - const {baseUrl, clientId, clientSecret, sessionExpirySeconds: configuredExpiry} = config; + config: HandleRefreshTokenConfig, +): Promise => { + const {baseUrl, clientId, clientSecret, sessionCookieExpiryTime: configuredExpiry} = config; const {refreshToken: storedRefreshToken, sessionId, sub, scopes, organizationId} = sessionPayload; if (!storedRefreshToken) { @@ -94,13 +92,13 @@ const tokenRefreshCore = async ( } const newAccessToken: string = tokenData['access_token'] as string; - const expiresIn: number = (tokenData['expires_in'] as number | undefined) ?? DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS; + const expiresIn: number = tokenData['expires_in'] as number; // Use the rotated refresh token if the server provided one; otherwise keep the existing one. const newRefreshToken: string = (tokenData['refresh_token'] as string | undefined) ?? storedRefreshToken; const newScopes: string = (tokenData['scope'] as string | undefined) ?? (Array.isArray(scopes) ? scopes.join(' ') : (scopes as string) ?? ''); - const resolvedSessionExpiry: number = SessionManager.resolveSessionExpiry(configuredExpiry); + const resolvedSessionCookieExpiry: number = SessionManager.resolveSessionCookieExpiry(configuredExpiry); const newSessionToken: string = await SessionManager.createSessionToken( newAccessToken, @@ -114,7 +112,7 @@ const tokenRefreshCore = async ( return { newSessionToken, - sessionExpirySeconds: resolvedSessionExpiry, + sessionCookieExpiryTime: resolvedSessionCookieExpiry, tokenResponse: { accessToken: newAccessToken, createdAt: Math.floor(Date.now() / 1000), @@ -127,4 +125,4 @@ const tokenRefreshCore = async ( }; }; -export default tokenRefreshCore; +export default handleRefreshToken; diff --git a/packages/node/src/models/config.ts b/packages/node/src/models/config.ts index 3e72be224..d8b17f37e 100644 --- a/packages/node/src/models/config.ts +++ b/packages/node/src/models/config.ts @@ -28,4 +28,19 @@ import {Config} from '@asgardeo/javascript'; * - Authentication parameters * - Session management options */ -export type AsgardeoNodeConfig = Config; +export type AsgardeoNodeConfig = Config & { + /** + * Session cookie lifetime in seconds. Determines how long the session cookie + * remains valid in the browser after sign-in. + * + * Resolution order (first defined value wins): + * 1. This field — set programmatically at SDK initialisation. + * 2. `ASGARDEO_SESSION_COOKIE_EXPIRY_TIME` environment variable. + * 3. Built-in default of 86400 seconds (24 hours). + * + * @example + * // 8-hour session cookie + * { sessionCookieExpiryTime: 28800 } + */ + sessionCookieExpiryTime?: number; +}; From 8e38b459a0d602a2b9104d709f84e487fa5de831 Mon Sep 17 00:00:00 2001 From: kavindadewmith Date: Tue, 21 Apr 2026 12:25:24 +0530 Subject: [PATCH 08/12] Refactor configuration retrieval to remove unnecessary type casting in OAuth actions --- packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts | 2 +- packages/nextjs/src/server/actions/refreshToken.ts | 2 +- packages/nextjs/src/server/actions/signInAction.ts | 2 +- packages/nextjs/src/server/actions/switchOrganization.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts b/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts index 2e17eef90..77d79dfa1 100644 --- a/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts +++ b/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts @@ -99,7 +99,7 @@ const handleOAuthCallbackAction = async ( sessionId, ); - const config: AsgardeoNextConfig = await (asgardeoClient.getConfiguration() as unknown as Promise); + const config: AsgardeoNextConfig = await asgardeoClient.getConfiguration(); if (signInResult) { try { diff --git a/packages/nextjs/src/server/actions/refreshToken.ts b/packages/nextjs/src/server/actions/refreshToken.ts index 082a2eed7..00fcd92ca 100644 --- a/packages/nextjs/src/server/actions/refreshToken.ts +++ b/packages/nextjs/src/server/actions/refreshToken.ts @@ -69,7 +69,7 @@ const refreshToken = async (): Promise => { const sessionPayload: SessionTokenPayload = await SessionManager.verifySessionTokenForRefresh(sessionToken); const client: AsgardeoNextClient = AsgardeoNextClient.getInstance(); - const config: AsgardeoNextConfig = await (client.getConfiguration() as unknown as Promise); + const config: AsgardeoNextConfig = await client.getConfiguration(); const result: HandleRefreshTokenResult = await handleRefreshToken(sessionPayload, { baseUrl: config.baseUrl ?? '', diff --git a/packages/nextjs/src/server/actions/signInAction.ts b/packages/nextjs/src/server/actions/signInAction.ts index aaab410ec..73fbbd724 100644 --- a/packages/nextjs/src/server/actions/signInAction.ts +++ b/packages/nextjs/src/server/actions/signInAction.ts @@ -131,7 +131,7 @@ const signInAction = async ( | string | undefined; const expiresIn: number = signInResult['expiresIn'] as number; - const config: AsgardeoNextConfig = await (client.getConfiguration() as unknown as Promise); + const config: AsgardeoNextConfig = await client.getConfiguration(); const sessionCookieExpiryTime: number = SessionManager.resolveSessionCookieExpiry(config.sessionCookieExpiryTime); const sessionToken: string = await SessionManager.createSessionToken( diff --git a/packages/nextjs/src/server/actions/switchOrganization.ts b/packages/nextjs/src/server/actions/switchOrganization.ts index 37dc2b657..1cfd8139d 100644 --- a/packages/nextjs/src/server/actions/switchOrganization.ts +++ b/packages/nextjs/src/server/actions/switchOrganization.ts @@ -51,7 +51,7 @@ const switchOrganization = async ( const organizationId: string | undefined = (idToken['user_org'] || idToken['organization_id']) as | string | undefined; - const config: AsgardeoNextConfig = await (client.getConfiguration() as unknown as Promise); + const config: AsgardeoNextConfig = await client.getConfiguration(); const sessionCookieExpiryTime: number = SessionManager.resolveSessionCookieExpiry(config.sessionCookieExpiryTime); const expiresIn: number = parseInt(tokenResponse.expiresIn, 10); From 0f75633893ad5c10a59e6edba517ef028b5bd5f2 Mon Sep 17 00:00:00 2001 From: kavindadewmith Date: Tue, 21 Apr 2026 14:23:06 +0530 Subject: [PATCH 09/12] Fix lint errors --- .../contexts/Asgardeo/AsgardeoProvider.tsx | 2 +- .../nextjs/src/server/AsgardeoProvider.tsx | 20 +++++++++---------- .../actions/handleOAuthCallbackAction.ts | 4 +++- .../nextjs/src/server/actions/refreshToken.ts | 2 +- .../nextjs/src/server/actions/signInAction.ts | 4 +++- .../server/middleware/asgardeoMiddleware.ts | 4 ++-- 6 files changed, 20 insertions(+), 16 deletions(-) diff --git a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx index 9e5a09cdf..a362153e0 100644 --- a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx +++ b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx @@ -33,7 +33,6 @@ import { CreateOrganizationPayload, AsgardeoRuntimeError, } from '@asgardeo/node'; -import {RefreshResult} from '../../../server/actions/refreshToken'; import { I18nProvider, FlowProvider, @@ -49,6 +48,7 @@ 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 {RefreshResult} from '../../../server/actions/refreshToken'; import logger from '../../../utils/logger'; /** diff --git a/packages/nextjs/src/server/AsgardeoProvider.tsx b/packages/nextjs/src/server/AsgardeoProvider.tsx index 0c9f59759..366bc0d1f 100644 --- a/packages/nextjs/src/server/AsgardeoProvider.tsx +++ b/packages/nextjs/src/server/AsgardeoProvider.tsx @@ -18,9 +18,9 @@ 'use server'; -import { BrandingPreference, AsgardeoRuntimeError, IdToken, Organization, User, UserProfile } from '@asgardeo/node'; -import { AsgardeoProviderProps } from '@asgardeo/react'; -import { FC, PropsWithChildren, ReactElement } from 'react'; +import {BrandingPreference, AsgardeoRuntimeError, IdToken, Organization, User, UserProfile} from '@asgardeo/node'; +import {AsgardeoProviderProps} from '@asgardeo/react'; +import {FC, PropsWithChildren, ReactElement} from 'react'; import clearSession from './actions/clearSession'; import createOrganization from './actions/createOrganization'; import getAllOrganizations from './actions/getAllOrganizations'; @@ -41,9 +41,9 @@ import switchOrganization from './actions/switchOrganization'; import updateUserProfileAction from './actions/updateUserProfileAction'; import AsgardeoNextClient from '../AsgardeoNextClient'; import AsgardeoClientProvider from '../client/contexts/Asgardeo/AsgardeoProvider.js'; -import { AsgardeoNextConfig } from '../models/config'; +import {AsgardeoNextConfig} from '../models/config'; import logger from '../utils/logger'; -import { SessionTokenPayload } from '../utils/SessionManager'; +import {SessionTokenPayload} from '../utils/SessionManager'; /** * Props interface of {@link AsgardeoServerProvider} @@ -136,13 +136,13 @@ const AsgardeoServerProvider: FC> if (sessionPayload?.organizationId) { updatedBaseUrl = `${config?.baseUrl}/o`; - config = { ...config, baseUrl: updatedBaseUrl }; + config = {...config, baseUrl: updatedBaseUrl}; } else if (sessionId) { try { const idToken: IdToken = await asgardeoClient.getDecodedIdToken(sessionId); if (idToken?.['user_org']) { updatedBaseUrl = `${config?.baseUrl}/o`; - config = { ...config, baseUrl: updatedBaseUrl }; + config = {...config, baseUrl: updatedBaseUrl}; } } catch { // Continue without organization info @@ -157,12 +157,12 @@ const AsgardeoServerProvider: FC> if (shouldFetchUserProfile) { try { const userResponse: { - data: { user: User | null }; + data: {user: User | null}; error: string | null; success: boolean; } = await getUserAction(sessionId); const userProfileResponse: { - data: { userProfile: UserProfile }; + data: {userProfile: UserProfile}; error: string | null; success: boolean; } = await getUserProfileAction(sessionId); @@ -177,7 +177,7 @@ const AsgardeoServerProvider: FC> if (shouldFetchOrganizations) { try { const currentOrganizationResponse: { - data: { organization?: Organization; user?: Record }; + data: {organization?: Organization; user?: Record}; error: string | null; success: boolean; } = await getCurrentOrganizationAction(sessionId); diff --git a/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts b/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts index 77d79dfa1..37fdc5e4b 100644 --- a/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts +++ b/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts @@ -115,7 +115,9 @@ const handleOAuthCallbackAction = async ( | string | undefined; const expiresIn: number = signInResult['expiresIn'] as number; - const sessionCookieExpiryTime: number = SessionManager.resolveSessionCookieExpiry(config.sessionCookieExpiryTime); + const sessionCookieExpiryTime: number = SessionManager.resolveSessionCookieExpiry( + config.sessionCookieExpiryTime, + ); const sessionToken: string = await SessionManager.createSessionToken( accessToken, diff --git a/packages/nextjs/src/server/actions/refreshToken.ts b/packages/nextjs/src/server/actions/refreshToken.ts index 00fcd92ca..af20ffa24 100644 --- a/packages/nextjs/src/server/actions/refreshToken.ts +++ b/packages/nextjs/src/server/actions/refreshToken.ts @@ -22,8 +22,8 @@ import {AsgardeoAPIError, logger} from '@asgardeo/node'; import {cookies} from 'next/headers'; import AsgardeoNextClient from '../../AsgardeoNextClient'; import {AsgardeoNextConfig} from '../../models/config'; -import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager'; import handleRefreshToken, {HandleRefreshTokenResult} from '../../utils/handleRefreshToken'; +import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager'; type RequestCookies = Awaited>; diff --git a/packages/nextjs/src/server/actions/signInAction.ts b/packages/nextjs/src/server/actions/signInAction.ts index 73fbbd724..0835d6b2a 100644 --- a/packages/nextjs/src/server/actions/signInAction.ts +++ b/packages/nextjs/src/server/actions/signInAction.ts @@ -132,7 +132,9 @@ const signInAction = async ( | undefined; const expiresIn: number = signInResult['expiresIn'] as number; const config: AsgardeoNextConfig = await client.getConfiguration(); - const sessionCookieExpiryTime: number = SessionManager.resolveSessionCookieExpiry(config.sessionCookieExpiryTime); + const sessionCookieExpiryTime: number = SessionManager.resolveSessionCookieExpiry( + config.sessionCookieExpiryTime, + ); const sessionToken: string = await SessionManager.createSessionToken( accessToken, diff --git a/packages/nextjs/src/server/middleware/asgardeoMiddleware.ts b/packages/nextjs/src/server/middleware/asgardeoMiddleware.ts index 97d53663c..0abda1db2 100644 --- a/packages/nextjs/src/server/middleware/asgardeoMiddleware.ts +++ b/packages/nextjs/src/server/middleware/asgardeoMiddleware.ts @@ -17,12 +17,12 @@ */ import {NextRequest, NextResponse} from 'next/server'; -import {AsgardeoNextConfig} from '../../models/config'; import {REFRESH_BUFFER_SECONDS} from '../../constants/sessionConstants'; +import {AsgardeoNextConfig} from '../../models/config'; import decorateConfigWithNextEnv from '../../utils/decorateConfigWithNextEnv'; +import handleRefreshToken from '../../utils/handleRefreshToken'; import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager'; import {getSessionFromRequest, getSessionIdFromRequest} from '../../utils/sessionUtils'; -import handleRefreshToken from '../../utils/handleRefreshToken'; export type AsgardeoMiddlewareOptions = Partial; From 0fbf449ea13c3a10dbaad0b1a44a5f14e83d8e4d Mon Sep 17 00:00:00 2001 From: kavindadewmith Date: Tue, 21 Apr 2026 14:38:49 +0530 Subject: [PATCH 10/12] Fix expiresIn value handling in signInAction to ensure valid numeric conversion --- packages/nextjs/src/server/actions/signInAction.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/nextjs/src/server/actions/signInAction.ts b/packages/nextjs/src/server/actions/signInAction.ts index 0835d6b2a..24985fd94 100644 --- a/packages/nextjs/src/server/actions/signInAction.ts +++ b/packages/nextjs/src/server/actions/signInAction.ts @@ -130,11 +130,14 @@ const signInAction = async ( const organizationId: string | undefined = (idToken['user_org'] || idToken['organization_id']) as | string | undefined; - const expiresIn: number = signInResult['expiresIn'] as number; + const rawExpiresIn: unknown = signInResult['expiresIn'] ?? signInResult['expires_in']; + const expiresIn: number = Number(rawExpiresIn); + if (isNaN(expiresIn)) { + throw new Error(`[signInAction] Invalid expiresIn value received: ${rawExpiresIn}`); + } const config: AsgardeoNextConfig = await client.getConfiguration(); - const sessionCookieExpiryTime: number = SessionManager.resolveSessionCookieExpiry( - config.sessionCookieExpiryTime, - ); + const sessionCookieExpiryTime: number = + SessionManager.resolveSessionCookieExpiry(config.sessionCookieExpiryTime); const sessionToken: string = await SessionManager.createSessionToken( accessToken, From 93e9748638182552a32c8b9aa9d3e6814c239df6 Mon Sep 17 00:00:00 2001 From: kavindadewmith Date: Tue, 21 Apr 2026 14:49:48 +0530 Subject: [PATCH 11/12] Validate expiresIn value in refreshToken to ensure proper numeric conversion and error handling --- packages/nextjs/src/server/actions/refreshToken.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/nextjs/src/server/actions/refreshToken.ts b/packages/nextjs/src/server/actions/refreshToken.ts index af20ffa24..cda10deaf 100644 --- a/packages/nextjs/src/server/actions/refreshToken.ts +++ b/packages/nextjs/src/server/actions/refreshToken.ts @@ -91,7 +91,11 @@ const refreshToken = async (): Promise => { logger.warn('[refreshToken] Could not write session cookie — called from SSR rendering context.'); } - const expiresInSeconds: number = parseInt(result.tokenResponse.expiresIn, 10); + const rawExpiresIn: string | undefined = result.tokenResponse.expiresIn; + const expiresInSeconds: number = parseInt(rawExpiresIn ?? '', 10); + if (isNaN(expiresInSeconds)) { + throw new Error(`[refreshToken] Invalid expiresIn value received: ${rawExpiresIn}`); + } const expiresAt: number = Math.floor(Date.now() / 1000) + expiresInSeconds; logger.debug('[refreshToken] Token refresh succeeded.'); From 455674e2d12e26964bfbd205f661a8a721ba685f Mon Sep 17 00:00:00 2001 From: kavindadewmith Date: Tue, 21 Apr 2026 14:52:17 +0530 Subject: [PATCH 12/12] Fix lint errors --- packages/nextjs/src/server/actions/refreshToken.ts | 2 +- packages/nextjs/src/server/actions/signInAction.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/nextjs/src/server/actions/refreshToken.ts b/packages/nextjs/src/server/actions/refreshToken.ts index cda10deaf..a8dd65df4 100644 --- a/packages/nextjs/src/server/actions/refreshToken.ts +++ b/packages/nextjs/src/server/actions/refreshToken.ts @@ -93,7 +93,7 @@ const refreshToken = async (): Promise => { const rawExpiresIn: string | undefined = result.tokenResponse.expiresIn; const expiresInSeconds: number = parseInt(rawExpiresIn ?? '', 10); - if (isNaN(expiresInSeconds)) { + if (Number.isNaN(expiresInSeconds)) { throw new Error(`[refreshToken] Invalid expiresIn value received: ${rawExpiresIn}`); } const expiresAt: number = Math.floor(Date.now() / 1000) + expiresInSeconds; diff --git a/packages/nextjs/src/server/actions/signInAction.ts b/packages/nextjs/src/server/actions/signInAction.ts index 24985fd94..82b1a19d8 100644 --- a/packages/nextjs/src/server/actions/signInAction.ts +++ b/packages/nextjs/src/server/actions/signInAction.ts @@ -132,12 +132,13 @@ const signInAction = async ( | undefined; const rawExpiresIn: unknown = signInResult['expiresIn'] ?? signInResult['expires_in']; const expiresIn: number = Number(rawExpiresIn); - if (isNaN(expiresIn)) { + if (Number.isNaN(expiresIn)) { throw new Error(`[signInAction] Invalid expiresIn value received: ${rawExpiresIn}`); } const config: AsgardeoNextConfig = await client.getConfiguration(); - const sessionCookieExpiryTime: number = - SessionManager.resolveSessionCookieExpiry(config.sessionCookieExpiryTime); + const sessionCookieExpiryTime: number = SessionManager.resolveSessionCookieExpiry( + config.sessionCookieExpiryTime, + ); const sessionToken: string = await SessionManager.createSessionToken( accessToken,