diff --git a/packages/javascript/esbuild.config.mjs b/packages/javascript/esbuild.config.mjs index 75bd11607..1fc15925f 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, + 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/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..cd85c372d 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": { + "types": "./dist/types/middleware.d.ts", + "edge-light": "./dist/esm/middleware.js", + "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..65cbd1a33 100644 --- a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts +++ b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts @@ -20,11 +20,15 @@ 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; +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({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 475192363..a362153e0 100644 --- a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx +++ b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx @@ -48,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'; /** @@ -57,6 +58,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; @@ -68,6 +70,7 @@ export type AsgardeoClientProviderProps = Partial Promise; revalidateMyOrganizations?: (sessionId?: string) => Promise; signIn: AsgardeoContextProps['signIn']; signOut: AsgardeoContextProps['signOut']; @@ -85,6 +88,8 @@ const AsgardeoClientProvider: FC> baseUrl, children, signIn, + clearSession, + refreshToken, signOut, signUp, handleOAuthCallback, @@ -292,9 +297,11 @@ const AsgardeoClientProvider: FC> () => ({ applicationId, baseUrl, + clearSession, isLoading, isSignedIn, organizationHandle, + refreshToken, signIn: handleSignIn, signInUrl, signOut: handleSignOut, diff --git a/packages/nextjs/src/constants/sessionConstants.ts b/packages/nextjs/src/constants/sessionConstants.ts new file mode 100644 index 000000000..4f5eb65ad --- /dev/null +++ b/packages/nextjs/src/constants/sessionConstants.ts @@ -0,0 +1,50 @@ +/** + * 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 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 session cookie lifetime in seconds (24 hours). + * + * Used when no explicit session cookie expiry is configured. The session cookie + * lifetime can be overridden in two ways (evaluated in this order): + * + * 1. `sessionCookieExpiryTime` in `AsgardeoNodeConfig` — set programmatically + * when initialising the SDK. + * 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 + * 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.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_COOKIE_EXPIRY_TIME: number = 86400; 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/server/AsgardeoProvider.tsx b/packages/nextjs/src/server/AsgardeoProvider.tsx index e6b0ad085..366bc0d1f 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,6 +33,7 @@ import getUserAction from './actions/getUserAction'; import getUserProfileAction from './actions/getUserProfileAction'; import handleOAuthCallbackAction from './actions/handleOAuthCallbackAction'; import isSignedIn from './actions/isSignedIn'; +import refreshToken from './actions/refreshToken'; import signInAction from './actions/signInAction'; import signOutAction from './actions/signOutAction'; import signUpAction from './actions/signUpAction'; @@ -48,6 +50,20 @@ import {SessionTokenPayload} from '../utils/SessionManager'; */ 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; }; /** @@ -99,7 +115,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 +219,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..cdb79d42a --- /dev/null +++ b/packages/nextjs/src/server/actions/clearSession.ts @@ -0,0 +1,54 @@ +/** + * 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 {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. + * + * 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: RequestCookies = 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/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 5acfdbbb1..37fdc5e4b 100644 --- a/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts +++ b/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts @@ -19,13 +19,14 @@ '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'; 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 @@ -62,7 +63,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; @@ -98,6 +99,8 @@ const handleOAuthCallbackAction = async ( sessionId, ); + const config: AsgardeoNextConfig = await asgardeoClient.getConfiguration(); + if (signInResult) { try { const idToken: IdToken = await asgardeoClient.getDecodedIdToken( @@ -105,21 +108,32 @@ 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; + const sessionCookieExpiryTime: number = SessionManager.resolveSessionCookieExpiry( + config.sessionCookieExpiryTime, + ); 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(sessionCookieExpiryTime), + ); cookieStore.delete(SessionManager.getTempSessionCookieName()); } catch (error) { @@ -130,7 +144,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..a8dd65df4 --- /dev/null +++ b/packages/nextjs/src/server/actions/refreshToken.ts @@ -0,0 +1,125 @@ +/** + * 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} from '@asgardeo/node'; +import {cookies} from 'next/headers'; +import AsgardeoNextClient from '../../AsgardeoNextClient'; +import {AsgardeoNextConfig} from '../../models/config'; +import handleRefreshToken, {HandleRefreshTokenResult} from '../../utils/handleRefreshToken'; +import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager'; + +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. + * + * Delegates the HTTP exchange to handleRefreshToken so the same logic is shared + * with the middleware token refresh path. + * + * Called from the client side (e.g. AsgardeoClientProvider refreshOnMount) where + * Next.js allows cookie mutation. When invoked during SSR rendering the cookie + * write is silently skipped and a warning is logged. + */ +const refreshToken = async (): Promise => { + try { + const cookieStore: RequestCookies = 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.verifySessionTokenForRefresh(sessionToken); + const client: AsgardeoNextClient = AsgardeoNextClient.getInstance(); + const config: AsgardeoNextConfig = await client.getConfiguration(); + + const result: HandleRefreshTokenResult = await handleRefreshToken(sessionPayload, { + baseUrl: config.baseUrl ?? '', + clientId: config.clientId ?? '', + clientSecret: config.clientSecret ?? '', + sessionCookieExpiryTime: config.sessionCookieExpiryTime, + }); + + try { + cookieStore.set( + SessionManager.getSessionCookieName(), + result.newSessionToken, + SessionManager.getSessionCookieOptions(result.sessionCookieExpiryTime), + ); + } 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.'); + } + + const rawExpiresIn: string | undefined = result.tokenResponse.expiresIn; + const expiresInSeconds: number = parseInt(rawExpiresIn ?? '', 10); + if (Number.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.'); + 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 + // rendering context Next.js blocks cookie mutation; the middleware cleanup + // path covers that case on the next request. + try { + const cookieStore: RequestCookies = 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..82b1a19d8 100644 --- a/packages/nextjs/src/server/actions/signInAction.ts +++ b/packages/nextjs/src/server/actions/signInAction.ts @@ -27,11 +27,14 @@ 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'; +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. @@ -55,7 +58,7 @@ const signInAction = async ( }> => { try { const client: AsgardeoNextClient = AsgardeoNextClient.getInstance(); - const cookieStore: ReadonlyRequestCookies = await cookies(); + const cookieStore: RequestCookies = await cookies(); let sessionId: string | undefined; @@ -116,23 +119,42 @@ 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 rawExpiresIn: unknown = signInResult['expiresIn'] ?? signInResult['expires_in']; + const expiresIn: number = Number(rawExpiresIn); + 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 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(sessionCookieExpiryTime), + ); cookieStore.delete(SessionManager.getTempSessionCookieName()); } @@ -143,8 +165,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/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 64d095051..1cfd8139d 100644 --- a/packages/nextjs/src/server/actions/switchOrganization.ts +++ b/packages/nextjs/src/server/actions/switchOrganization.ts @@ -19,13 +19,15 @@ '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'; +import {AsgardeoNextConfig} from '../../models/config'; import logger from '../../utils/logger'; import SessionManager from '../../utils/SessionManager'; +type RequestCookies = Awaited>; + /** * Server action to switch organization. */ @@ -34,38 +36,42 @@ 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); - // 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 = await client.getConfiguration(); + const sessionCookieExpiryTime: number = SessionManager.resolveSessionCookieExpiry(config.sessionCookieExpiryTime); + const expiresIn: number = parseInt(tokenResponse.expiresIn, 10); 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(sessionCookieExpiryTime), + ); } return response; diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 7fca31529..49f3ec784 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -22,8 +22,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..0abda1db2 100644 --- a/packages/nextjs/src/server/middleware/asgardeoMiddleware.ts +++ b/packages/nextjs/src/server/middleware/asgardeoMiddleware.ts @@ -17,13 +17,12 @@ */ import {NextRequest, NextResponse} from 'next/server'; +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 { - hasValidSession as hasValidJWTSession, - getSessionFromRequest, - getSessionIdFromRequest, -} from '../../utils/sessionUtils'; +import {getSessionFromRequest, getSessionIdFromRequest} from '../../utils/sessionUtils'; export type AsgardeoMiddlewareOptions = Partial; @@ -41,7 +40,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 +51,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: 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; + }) + .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: string) => p.trim()) + .filter(Boolean); + + let found: boolean = false; + 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) { + 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 +139,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 = ( @@ -139,92 +148,203 @@ const asgardeoMiddleware = async (request: NextRequest): Promise => { 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, 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 handleRefreshToken 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 { - return await getSessionFromRequest(request); + const decoded: SessionTokenPayload = await SessionManager.verifySessionTokenForRefresh(rawToken); + if (decoded.refreshToken) { + expiredSession = decoded; + } } catch { - return undefined; + // Forged, tampered, wrong type, or malformed — 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: {expiry: number; token: string} | undefined; + + if (needsRefresh && candidateSession) { + try { + const {newSessionToken, sessionCookieExpiryTime} = await handleRefreshToken(candidateSession, { + baseUrl: resolvedConfig.baseUrl!, + clientId: resolvedConfig.clientId!, + clientSecret: resolvedConfig.clientSecret!, + sessionCookieExpiryTime: resolvedConfig.sessionCookieExpiryTime, + }); + // Verify the newly minted token so activeSession reflects fresh claims. + activeSession = await SessionManager.verifySessionToken(newSessionToken); + refreshCookieUpdate = {expiry: sessionCookieExpiryTime, token: newSessionToken}; + } 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 redirectUrl: string = + routeOptions?.redirect ?? (resolvedConfig.signInUrl as string) ?? fallbackRedirect; - const signInUrl: URL = new URL(redirectUrl, request.url); - - return NextResponse.redirect(signInUrl); + return NextResponse.redirect(new URL(redirectUrl, request.url)); } return undefined; }, }; - if (handler) { - const result: NextResponse | void = await handler(asgardeo, request); - if (result) { - return result; + // ── Handler ─────────────────────────────────────────────────────────────── + const handlerResponse: NextResponse | void = handler ? await handler(asgardeo, request) : undefined; + + // ── 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: ReturnType = + 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..ba89cd229 100644 --- a/packages/nextjs/src/utils/SessionManager.ts +++ b/packages/nextjs/src/utils/SessionManager.ts @@ -17,32 +17,35 @@ */ import {AsgardeoRuntimeError, CookieConfig} from '@asgardeo/node'; -import {SignJWT, jwtVerify, JWTPayload} from 'jose'; +import {SignJWT, jwtVerify, compactVerify, JWTPayload} from 'jose'; +import {DEFAULT_SESSION_COOKIE_EXPIRY_TIME} from '../constants/sessionConstants'; /** * 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 */ sessionId: string; /** User ID */ sub: string; + /** Token type discriminant — must be 'session' for access-session JWTs */ + type: 'session'; } /** * 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 * Throws error in production if not set @@ -87,21 +90,46 @@ class SessionManager { } /** - * Create a session cookie with user information + * Resolve the session cookie expiry time in seconds. + * + * Resolution order (first defined value wins): + * 1. `configuredExpiry` — value from `AsgardeoNodeConfig.sessionCookieExpiryTime` + * 2. `ASGARDEO_SESSION_COOKIE_EXPIRY_TIME` environment variable + * 3. `DEFAULT_SESSION_COOKIE_EXPIRY_TIME` (24 hours) */ + static resolveSessionCookieExpiry(configuredExpiry?: number): number { + if (configuredExpiry != null && configuredExpiry > 0) { + return configuredExpiry; + } + + const envValue: string | undefined = process.env['ASGARDEO_SESSION_COOKIE_EXPIRY_TIME']; + + if (envValue) { + const parsed: number = parseInt(envValue, 10); + + if (!Number.isNaN(parsed) && parsed > 0) { + return parsed; + } + } + + return DEFAULT_SESSION_COOKIE_EXPIRY_TIME; + } + 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,7 +137,7 @@ class SessionManager { .setProtectedHeader({alg: 'HS256'}) .setSubject(userId) .setIssuedAt() - .setExpirationTime(Date.now() / 1000 + expirySeconds) + .setExpirationTime(Math.floor(Date.now() / 1000) + accessTokenTtlSeconds) .sign(secret); return jwt; @@ -123,6 +151,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( @@ -134,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 + * `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. + */ + 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 */ @@ -160,7 +224,7 @@ class SessionManager { /** * Get session cookie options */ - static getSessionCookieOptions(): { + static getSessionCookieOptions(maxAge: number): { httpOnly: boolean; maxAge: number; path: string; @@ -169,7 +233,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/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/handleRefreshToken.ts b/packages/nextjs/src/utils/handleRefreshToken.ts new file mode 100644 index 000000000..e0a6c9368 --- /dev/null +++ b/packages/nextjs/src/utils/handleRefreshToken.ts @@ -0,0 +1,128 @@ +/** + * 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 SessionManager, {SessionTokenPayload} from './SessionManager'; + +/** + * Config required to call the token endpoint. + */ +export interface HandleRefreshTokenConfig { + baseUrl: string; + clientId: string; + clientSecret: string; + sessionCookieExpiryTime?: number; +} + +/** + * Result returned by handleRefreshToken. + * Callers are responsible for persisting newSessionToken in the appropriate cookie context. + */ +export interface HandleRefreshTokenResult { + newSessionToken: string; + sessionCookieExpiryTime: number; + tokenResponse: TokenResponse; +} + +/** + * 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 handleRefreshToken = async ( + sessionPayload: SessionTokenPayload, + config: HandleRefreshTokenConfig, +): Promise => { + const {baseUrl, clientId, clientSecret, sessionCookieExpiryTime: 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; + // 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 resolvedSessionCookieExpiry: number = SessionManager.resolveSessionCookieExpiry(configuredExpiry); + + const newSessionToken: string = await SessionManager.createSessionToken( + newAccessToken, + sub as string, + sessionId, + newScopes, + expiresIn, + newRefreshToken, + organizationId, + ); + + return { + newSessionToken, + sessionCookieExpiryTime: resolvedSessionCookieExpiry, + 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 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; +};