diff --git a/.changeset/soft-kiwis-drive.md b/.changeset/soft-kiwis-drive.md new file mode 100644 index 000000000..8a4e91903 --- /dev/null +++ b/.changeset/soft-kiwis-drive.md @@ -0,0 +1,5 @@ +--- +'@asgardeo/nextjs': patch +--- + +Enhance route protection with createRouteMatcher and session validation diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index 40ceb8449..d2f2bb635 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -68,3 +68,7 @@ export type {UserProfileProps} from './client/components/presentation/UserProfil export {default as AsgardeoNext} from './AsgardeoNextClient'; export {default as asgardeoMiddleware} from './middleware/asgardeoMiddleware'; +export * from './middleware/asgardeoMiddleware'; + +export {default as createRouteMatcher} from './middleware/createRouteMatcher'; +export * from './middleware/createRouteMatcher'; diff --git a/packages/nextjs/src/middleware/asgardeoMiddleware.ts b/packages/nextjs/src/middleware/asgardeoMiddleware.ts index abcbd3fdf..fdcc72b39 100644 --- a/packages/nextjs/src/middleware/asgardeoMiddleware.ts +++ b/packages/nextjs/src/middleware/asgardeoMiddleware.ts @@ -17,25 +17,55 @@ */ import {NextRequest, NextResponse} from 'next/server'; -import AsgardeoNextClient from '../AsgardeoNextClient'; +import {CookieConfig} from '@asgardeo/node'; import {AsgardeoNextConfig} from '../models/config'; -export interface AsgardeoMiddlewareOptions extends Partial { - debug?: boolean; -} - -type AsgardeoAuth = { - protect: (options?: {redirect?: string}) => Promise; - isSignedIn: () => Promise; - getUser: () => Promise; - redirectToSignIn: (afterSignInUrl?: string) => NextResponse; +export type AsgardeoMiddlewareOptions = Partial; + +export type AsgardeoMiddlewareContext = { + /** + * Protect a route by redirecting unauthenticated users. + * Redirect URL fallback order: + * 1. options.redirect + * 2. resolvedOptions.signInUrl + * 3. resolvedOptions.defaultRedirect + * 4. referer (if from same origin) + * If none are available, throws an error. + */ + protectRoute: (options?: {redirect?: string}) => Promise; + /** Check if the current request has a valid Asgardeo session */ + isSignedIn: () => boolean; + /** Get the session ID from the current request */ + getSessionId: () => string | undefined; }; type AsgardeoMiddlewareHandler = ( - auth: AsgardeoAuth, + asgardeo: AsgardeoMiddlewareContext, req: NextRequest, ) => Promise | NextResponse | void; +/** + * Checks if a request has a valid session ID in cookies. + * This is a lightweight check that can be used in middleware. + * + * @param request - The Next.js request object + * @returns True if a session ID exists, false otherwise + */ +const hasValidSession = (request: NextRequest): boolean => { + const sessionId = request.cookies.get(CookieConfig.SESSION_COOKIE_NAME)?.value; + return Boolean(sessionId && sessionId.trim().length > 0); +}; + +/** + * Gets the session ID from the request cookies. + * + * @param request - The Next.js request object + * @returns The session ID if it exists, undefined otherwise + */ +const getSessionIdFromRequest = (request: NextRequest): string | undefined => { + return request.cookies.get(CookieConfig.SESSION_COOKIE_NAME)?.value; +}; + /** * Asgardeo middleware that integrates authentication into your Next.js application. * Similar to Clerk's clerkMiddleware pattern. @@ -46,7 +76,7 @@ type AsgardeoMiddlewareHandler = ( * * @example * ```typescript - * // middleware.ts + * // middleware.ts - Basic usage * import { asgardeoMiddleware } from '@asgardeo/nextjs'; * * export default asgardeoMiddleware(); @@ -54,15 +84,41 @@ type AsgardeoMiddlewareHandler = ( * * @example * ```typescript - * // With protection + * // With route protection * import { asgardeoMiddleware, createRouteMatcher } from '@asgardeo/nextjs'; * * const isProtectedRoute = createRouteMatcher(['/dashboard(.*)']); * - * export default asgardeoMiddleware(async (auth, req) => { + * export default asgardeoMiddleware(async (asgardeo, req) => { * if (isProtectedRoute(req)) { - * await auth.protect(); + * await asgardeo.protectRoute(); + * } + * }); + * ``` + * + * @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' * }); * ``` */ @@ -71,78 +127,53 @@ const asgardeoMiddleware = ( options?: AsgardeoMiddlewareOptions | ((req: NextRequest) => AsgardeoMiddlewareOptions), ): ((request: NextRequest) => Promise) => { return async (request: NextRequest): Promise => { - // Resolve options - can be static or dynamic based on request const resolvedOptions = typeof options === 'function' ? options(request) : options || {}; - const asgardeoClient = AsgardeoNextClient.getInstance(); - - // // Initialize client if not already done - // if (!asgardeoClient.isInitialized && resolvedOptions) { - // asgardeoClient.initialize(resolvedOptions); - // } - - // // Debug logging - // if (resolvedOptions.debug) { - // console.log(`[Asgardeo Middleware] Processing request: ${request.nextUrl.pathname}`); - // } - - // // Handle auth API routes automatically - // if (request.nextUrl.pathname.startsWith('/api/auth/asgardeo')) { - // if (resolvedOptions.debug) { - // console.log(`[Asgardeo Middleware] Handling auth route: ${request.nextUrl.pathname}`); - // } - // return await asgardeoClient.handleAuthRequest(request); - // } - - // // Create auth object for the handler - // const auth: AsgardeoAuth = { - // protect: async (options?: {redirect?: string}) => { - // const isSignedIn = await asgardeoClient.isSignedIn(request); - // if (!isSignedIn) { - // const afterSignInUrl = options?.redirect || '/api/auth/asgardeo/signin'; - // return NextResponse.redirect(new URL(afterSignInUrl, request.url)); - // } - // }, - - // isSignedIn: async () => { - // return await asgardeoClient.isSignedIn(request); - // }, - - // getUser: async () => { - // return await asgardeoClient.getUser(request); - // }, - - // redirectToSignIn: (afterSignInUrl?: string) => { - // const signInUrl = afterSignInUrl || '/api/auth/asgardeo/signin'; - // return NextResponse.redirect(new URL(signInUrl, request.url)); - // }, - // }; - - // // Execute user-provided handler if present - // let handlerResponse: NextResponse | void; - // if (handler) { - // handlerResponse = await handler(auth, request); - // } - - // // If handler returned a response, use it - // if (handlerResponse) { - // return handlerResponse; - // } - - // // Otherwise, continue with default behavior - // const response = NextResponse.next(); - - // // Add authentication context to response headers - // const isSignedIn = await asgardeoClient.isSignedIn(request); - // if (isSignedIn) { - // response.headers.set('x-asgardeo-authenticated', 'true'); - // const user = await asgardeoClient.getUser(request); - // if (user?.sub) { - // response.headers.set('x-asgardeo-user-id', user.sub); - // } - // } - - // return response; + const sessionId = request.cookies.get(CookieConfig.SESSION_COOKIE_NAME)?.value; + const isAuthenticated = hasValidSession(request); + + const asgardeo: AsgardeoMiddlewareContext = { + protectRoute: async (options?: {redirect?: string}): Promise => { + if (!isAuthenticated) { + const referer = 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 = new URL(referer); + const requestUrl = new URL(request.url); + + if (refererUrl.origin === requestUrl.origin) { + fallbackRedirect = refererUrl.pathname + refererUrl.search; + } + } catch (error) { + // Invalid referer URL, ignore it + } + } + + // Fallback chain: options.redirect -> resolvedOptions.signInUrl -> resolvedOptions.defaultRedirect -> referer (same origin only) + const redirectUrl: string = (resolvedOptions?.signInUrl as string) || fallbackRedirect; + + const signInUrl = new URL(redirectUrl, request.url); + + return NextResponse.redirect(signInUrl); + } + + // Session exists, allow access + return; + }, + isSignedIn: () => isAuthenticated, + getSessionId: () => sessionId, + }; + + if (handler) { + const result = await handler(asgardeo, request); + if (result) { + return result; + } + } return NextResponse.next(); }; diff --git a/packages/nextjs/src/middleware/createRouteMatcher.ts b/packages/nextjs/src/middleware/createRouteMatcher.ts new file mode 100644 index 000000000..a55eb34f7 --- /dev/null +++ b/packages/nextjs/src/middleware/createRouteMatcher.ts @@ -0,0 +1,57 @@ +/** + * 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 {NextRequest} from 'next/server'; + +/** + * Creates a route matcher function that tests if a request matches any of the given patterns. + * + * @param patterns - Array of route patterns to match. Supports glob-like patterns. + * @returns Function that tests if a request matches any of the patterns + * + * @example + * ```typescript + * const isProtectedRoute = createRouteMatcher([ + * '/dashboard(.*)', + * '/admin(.*)', + * '/profile' + * ]); + * + * if (isProtectedRoute(req)) { + * // Route is protected + * } + * ``` + */ +const createRouteMatcher = (patterns: string[]) => { + const regexPatterns = patterns.map(pattern => { + // Convert glob-like patterns to regex + const regexPattern = pattern + .replace(/\./g, '\\.') // Escape dots + .replace(/\*/g, '.*') // Convert * to .* + .replace(/\(\.\*\)/g, '(.*)'); // Handle explicit (.*) patterns + + return new RegExp(`^${regexPattern}$`); + }); + + return (req: NextRequest): boolean => { + const pathname = req.nextUrl.pathname; + return regexPatterns.some(regex => regex.test(pathname)); + }; +}; + +export default createRouteMatcher; diff --git a/packages/nextjs/src/utils/createRouteMatcher.ts b/packages/nextjs/src/utils/createRouteMatcher.ts new file mode 100644 index 000000000..7af031fa8 --- /dev/null +++ b/packages/nextjs/src/utils/createRouteMatcher.ts @@ -0,0 +1,55 @@ +/** + * 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 {NextRequest} from 'next/server'; + +/** + * Creates a route matcher function that tests if a request matches any of the given patterns. + * + * @param patterns - Array of route patterns to match. Supports glob-like patterns. + * @returns Function that tests if a request matches any of the patterns + * + * @example + * ```typescript + * const isProtectedRoute = createRouteMatcher([ + * '/dashboard(.*)', + * '/admin(.*)', + * '/profile' + * ]); + * + * if (isProtectedRoute(req)) { + * // Route is protected + * } + * ``` + */ +export const createRouteMatcher = (patterns: string[]) => { + const regexPatterns = patterns.map(pattern => { + // Convert glob-like patterns to regex + const regexPattern = pattern + .replace(/\./g, '\\.') // Escape dots + .replace(/\*/g, '.*') // Convert * to .* + .replace(/\(\.\*\)/g, '(.*)'); // Handle explicit (.*) patterns + + return new RegExp(`^${regexPattern}$`); + }); + + return (req: NextRequest): boolean => { + const pathname = req.nextUrl.pathname; + return regexPatterns.some(regex => regex.test(pathname)); + }; +}; diff --git a/packages/nextjs/src/utils/sessionUtils.ts b/packages/nextjs/src/utils/sessionUtils.ts new file mode 100644 index 000000000..aff97f681 --- /dev/null +++ b/packages/nextjs/src/utils/sessionUtils.ts @@ -0,0 +1,42 @@ +/** + * 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 {NextRequest} from 'next/server'; +import {CookieConfig} from '@asgardeo/node'; + +/** + * Checks if a request has a valid session ID in cookies. + * This is a lightweight check that can be used in middleware. + * + * @param request - The Next.js request object + * @returns True if a session ID exists, false otherwise + */ +export const hasValidSession = (request: NextRequest): boolean => { + const sessionId = request.cookies.get(CookieConfig.SESSION_COOKIE_NAME)?.value; + return Boolean(sessionId && sessionId.trim().length > 0); +}; + +/** + * Gets the session ID from the request cookies. + * + * @param request - The Next.js request object + * @returns The session ID if it exists, undefined otherwise + */ +export const getSessionIdFromRequest = (request: NextRequest): string | undefined => { + return request.cookies.get(CookieConfig.SESSION_COOKIE_NAME)?.value; +}; diff --git a/samples/teamspace-nextjs/middleware.ts b/samples/teamspace-nextjs/middleware.ts index 7f506e37f..88ea6e34d 100644 --- a/samples/teamspace-nextjs/middleware.ts +++ b/samples/teamspace-nextjs/middleware.ts @@ -1,6 +1,19 @@ -import {asgardeoMiddleware} from '@asgardeo/nextjs'; +import {asgardeoMiddleware, createRouteMatcher} from '@asgardeo/nextjs'; -export default asgardeoMiddleware(); +const isProtectedRoute = createRouteMatcher([ + '/dashboard', + '/dashboard/(.*)', +]); + +export default asgardeoMiddleware(async (asgardeo, req) => { + if (isProtectedRoute(req)) { + const protectionResult = await asgardeo.protectRoute(); + + if (protectionResult) { + return protectionResult; + } + } +}); export const config = { matcher: [