Skip to content

Commit 57eb93b

Browse files
authored
Merge pull request #102 from brionmario/next-user-components
feat(nextjs): enhance route protection with createRouteMatcher and session validation
2 parents 32230f7 + 6dd960a commit 57eb93b

File tree

7 files changed

+294
-87
lines changed

7 files changed

+294
-87
lines changed

.changeset/soft-kiwis-drive.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@asgardeo/nextjs': patch
3+
---
4+
5+
Enhance route protection with createRouteMatcher and session validation

packages/nextjs/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,7 @@ export type {UserProfileProps} from './client/components/presentation/UserProfil
6868
export {default as AsgardeoNext} from './AsgardeoNextClient';
6969

7070
export {default as asgardeoMiddleware} from './middleware/asgardeoMiddleware';
71+
export * from './middleware/asgardeoMiddleware';
72+
73+
export {default as createRouteMatcher} from './middleware/createRouteMatcher';
74+
export * from './middleware/createRouteMatcher';

packages/nextjs/src/middleware/asgardeoMiddleware.ts

Lines changed: 116 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -17,25 +17,55 @@
1717
*/
1818

1919
import {NextRequest, NextResponse} from 'next/server';
20-
import AsgardeoNextClient from '../AsgardeoNextClient';
20+
import {CookieConfig} from '@asgardeo/node';
2121
import {AsgardeoNextConfig} from '../models/config';
2222

23-
export interface AsgardeoMiddlewareOptions extends Partial<AsgardeoNextConfig> {
24-
debug?: boolean;
25-
}
26-
27-
type AsgardeoAuth = {
28-
protect: (options?: {redirect?: string}) => Promise<NextResponse | void>;
29-
isSignedIn: () => Promise<boolean>;
30-
getUser: () => Promise<any | null>;
31-
redirectToSignIn: (afterSignInUrl?: string) => NextResponse;
23+
export type AsgardeoMiddlewareOptions = Partial<AsgardeoNextConfig>;
24+
25+
export type AsgardeoMiddlewareContext = {
26+
/**
27+
* Protect a route by redirecting unauthenticated users.
28+
* Redirect URL fallback order:
29+
* 1. options.redirect
30+
* 2. resolvedOptions.signInUrl
31+
* 3. resolvedOptions.defaultRedirect
32+
* 4. referer (if from same origin)
33+
* If none are available, throws an error.
34+
*/
35+
protectRoute: (options?: {redirect?: string}) => Promise<NextResponse | void>;
36+
/** Check if the current request has a valid Asgardeo session */
37+
isSignedIn: () => boolean;
38+
/** Get the session ID from the current request */
39+
getSessionId: () => string | undefined;
3240
};
3341

3442
type AsgardeoMiddlewareHandler = (
35-
auth: AsgardeoAuth,
43+
asgardeo: AsgardeoMiddlewareContext,
3644
req: NextRequest,
3745
) => Promise<NextResponse | void> | NextResponse | void;
3846

47+
/**
48+
* Checks if a request has a valid session ID in cookies.
49+
* This is a lightweight check that can be used in middleware.
50+
*
51+
* @param request - The Next.js request object
52+
* @returns True if a session ID exists, false otherwise
53+
*/
54+
const hasValidSession = (request: NextRequest): boolean => {
55+
const sessionId = request.cookies.get(CookieConfig.SESSION_COOKIE_NAME)?.value;
56+
return Boolean(sessionId && sessionId.trim().length > 0);
57+
};
58+
59+
/**
60+
* Gets the session ID from the request cookies.
61+
*
62+
* @param request - The Next.js request object
63+
* @returns The session ID if it exists, undefined otherwise
64+
*/
65+
const getSessionIdFromRequest = (request: NextRequest): string | undefined => {
66+
return request.cookies.get(CookieConfig.SESSION_COOKIE_NAME)?.value;
67+
};
68+
3969
/**
4070
* Asgardeo middleware that integrates authentication into your Next.js application.
4171
* Similar to Clerk's clerkMiddleware pattern.
@@ -46,23 +76,49 @@ type AsgardeoMiddlewareHandler = (
4676
*
4777
* @example
4878
* ```typescript
49-
* // middleware.ts
79+
* // middleware.ts - Basic usage
5080
* import { asgardeoMiddleware } from '@asgardeo/nextjs';
5181
*
5282
* export default asgardeoMiddleware();
5383
* ```
5484
*
5585
* @example
5686
* ```typescript
57-
* // With protection
87+
* // With route protection
5888
* import { asgardeoMiddleware, createRouteMatcher } from '@asgardeo/nextjs';
5989
*
6090
* const isProtectedRoute = createRouteMatcher(['/dashboard(.*)']);
6191
*
62-
* export default asgardeoMiddleware(async (auth, req) => {
92+
* export default asgardeoMiddleware(async (asgardeo, req) => {
6393
* if (isProtectedRoute(req)) {
64-
* await auth.protect();
94+
* await asgardeo.protectRoute();
95+
* }
96+
* });
97+
* ```
98+
*
99+
* @example
100+
* ```typescript
101+
* // Advanced usage with custom logic
102+
* import { asgardeoMiddleware, createRouteMatcher } from '@asgardeo/nextjs';
103+
*
104+
* const isProtectedRoute = createRouteMatcher(['/dashboard(.*)']);
105+
* const isAuthRoute = createRouteMatcher(['/sign-in', '/sign-up']);
106+
*
107+
* export default asgardeoMiddleware(async (asgardeo, req) => {
108+
* // Skip protection for auth routes
109+
* if (isAuthRoute(req)) return;
110+
*
111+
* // Protect specified routes
112+
* if (isProtectedRoute(req)) {
113+
* await asgardeo.protectRoute({ redirect: '/sign-in' });
114+
* }
115+
*
116+
* // Check authentication status
117+
* if (asgardeo.isSignedIn()) {
118+
* console.log('User is authenticated with session:', asgardeo.getSessionId());
65119
* }
120+
* }, {
121+
* defaultRedirect: '/sign-in'
66122
* });
67123
* ```
68124
*/
@@ -71,78 +127,53 @@ const asgardeoMiddleware = (
71127
options?: AsgardeoMiddlewareOptions | ((req: NextRequest) => AsgardeoMiddlewareOptions),
72128
): ((request: NextRequest) => Promise<NextResponse>) => {
73129
return async (request: NextRequest): Promise<NextResponse> => {
74-
// Resolve options - can be static or dynamic based on request
75130
const resolvedOptions = typeof options === 'function' ? options(request) : options || {};
76131

77-
const asgardeoClient = AsgardeoNextClient.getInstance();
78-
79-
// // Initialize client if not already done
80-
// if (!asgardeoClient.isInitialized && resolvedOptions) {
81-
// asgardeoClient.initialize(resolvedOptions);
82-
// }
83-
84-
// // Debug logging
85-
// if (resolvedOptions.debug) {
86-
// console.log(`[Asgardeo Middleware] Processing request: ${request.nextUrl.pathname}`);
87-
// }
88-
89-
// // Handle auth API routes automatically
90-
// if (request.nextUrl.pathname.startsWith('/api/auth/asgardeo')) {
91-
// if (resolvedOptions.debug) {
92-
// console.log(`[Asgardeo Middleware] Handling auth route: ${request.nextUrl.pathname}`);
93-
// }
94-
// return await asgardeoClient.handleAuthRequest(request);
95-
// }
96-
97-
// // Create auth object for the handler
98-
// const auth: AsgardeoAuth = {
99-
// protect: async (options?: {redirect?: string}) => {
100-
// const isSignedIn = await asgardeoClient.isSignedIn(request);
101-
// if (!isSignedIn) {
102-
// const afterSignInUrl = options?.redirect || '/api/auth/asgardeo/signin';
103-
// return NextResponse.redirect(new URL(afterSignInUrl, request.url));
104-
// }
105-
// },
106-
107-
// isSignedIn: async () => {
108-
// return await asgardeoClient.isSignedIn(request);
109-
// },
110-
111-
// getUser: async () => {
112-
// return await asgardeoClient.getUser(request);
113-
// },
114-
115-
// redirectToSignIn: (afterSignInUrl?: string) => {
116-
// const signInUrl = afterSignInUrl || '/api/auth/asgardeo/signin';
117-
// return NextResponse.redirect(new URL(signInUrl, request.url));
118-
// },
119-
// };
120-
121-
// // Execute user-provided handler if present
122-
// let handlerResponse: NextResponse | void;
123-
// if (handler) {
124-
// handlerResponse = await handler(auth, request);
125-
// }
126-
127-
// // If handler returned a response, use it
128-
// if (handlerResponse) {
129-
// return handlerResponse;
130-
// }
131-
132-
// // Otherwise, continue with default behavior
133-
// const response = NextResponse.next();
134-
135-
// // Add authentication context to response headers
136-
// const isSignedIn = await asgardeoClient.isSignedIn(request);
137-
// if (isSignedIn) {
138-
// response.headers.set('x-asgardeo-authenticated', 'true');
139-
// const user = await asgardeoClient.getUser(request);
140-
// if (user?.sub) {
141-
// response.headers.set('x-asgardeo-user-id', user.sub);
142-
// }
143-
// }
144-
145-
// return response;
132+
const sessionId = request.cookies.get(CookieConfig.SESSION_COOKIE_NAME)?.value;
133+
const isAuthenticated = hasValidSession(request);
134+
135+
const asgardeo: AsgardeoMiddlewareContext = {
136+
protectRoute: async (options?: {redirect?: string}): Promise<NextResponse | void> => {
137+
if (!isAuthenticated) {
138+
const referer = request.headers.get('referer');
139+
// TODO: Make this configurable or call the signIn() from here.
140+
let fallbackRedirect: string = '/';
141+
142+
// If referer exists and is from the same origin, use it as fallback
143+
if (referer) {
144+
try {
145+
const refererUrl = new URL(referer);
146+
const requestUrl = new URL(request.url);
147+
148+
if (refererUrl.origin === requestUrl.origin) {
149+
fallbackRedirect = refererUrl.pathname + refererUrl.search;
150+
}
151+
} catch (error) {
152+
// Invalid referer URL, ignore it
153+
}
154+
}
155+
156+
// Fallback chain: options.redirect -> resolvedOptions.signInUrl -> resolvedOptions.defaultRedirect -> referer (same origin only)
157+
const redirectUrl: string = (resolvedOptions?.signInUrl as string) || fallbackRedirect;
158+
159+
const signInUrl = new URL(redirectUrl, request.url);
160+
161+
return NextResponse.redirect(signInUrl);
162+
}
163+
164+
// Session exists, allow access
165+
return;
166+
},
167+
isSignedIn: () => isAuthenticated,
168+
getSessionId: () => sessionId,
169+
};
170+
171+
if (handler) {
172+
const result = await handler(asgardeo, request);
173+
if (result) {
174+
return result;
175+
}
176+
}
146177

147178
return NextResponse.next();
148179
};
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com).
3+
*
4+
* WSO2 LLC. licenses this file to you under the Apache License,
5+
* Version 2.0 (the "License"); you may not use this file except
6+
* in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing,
12+
* software distributed under the License is distributed on an
13+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
* KIND, either express or implied. See the License for the
15+
* specific language governing permissions and limitations
16+
* under the License.
17+
*/
18+
19+
import {NextRequest} from 'next/server';
20+
21+
/**
22+
* Creates a route matcher function that tests if a request matches any of the given patterns.
23+
*
24+
* @param patterns - Array of route patterns to match. Supports glob-like patterns.
25+
* @returns Function that tests if a request matches any of the patterns
26+
*
27+
* @example
28+
* ```typescript
29+
* const isProtectedRoute = createRouteMatcher([
30+
* '/dashboard(.*)',
31+
* '/admin(.*)',
32+
* '/profile'
33+
* ]);
34+
*
35+
* if (isProtectedRoute(req)) {
36+
* // Route is protected
37+
* }
38+
* ```
39+
*/
40+
const createRouteMatcher = (patterns: string[]) => {
41+
const regexPatterns = patterns.map(pattern => {
42+
// Convert glob-like patterns to regex
43+
const regexPattern = pattern
44+
.replace(/\./g, '\\.') // Escape dots
45+
.replace(/\*/g, '.*') // Convert * to .*
46+
.replace(/\(\.\*\)/g, '(.*)'); // Handle explicit (.*) patterns
47+
48+
return new RegExp(`^${regexPattern}$`);
49+
});
50+
51+
return (req: NextRequest): boolean => {
52+
const pathname = req.nextUrl.pathname;
53+
return regexPatterns.some(regex => regex.test(pathname));
54+
};
55+
};
56+
57+
export default createRouteMatcher;
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com).
3+
*
4+
* WSO2 LLC. licenses this file to you under the Apache License,
5+
* Version 2.0 (the "License"); you may not use this file except
6+
* in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing,
12+
* software distributed under the License is distributed on an
13+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
* KIND, either express or implied. See the License for the
15+
* specific language governing permissions and limitations
16+
* under the License.
17+
*/
18+
19+
import {NextRequest} from 'next/server';
20+
21+
/**
22+
* Creates a route matcher function that tests if a request matches any of the given patterns.
23+
*
24+
* @param patterns - Array of route patterns to match. Supports glob-like patterns.
25+
* @returns Function that tests if a request matches any of the patterns
26+
*
27+
* @example
28+
* ```typescript
29+
* const isProtectedRoute = createRouteMatcher([
30+
* '/dashboard(.*)',
31+
* '/admin(.*)',
32+
* '/profile'
33+
* ]);
34+
*
35+
* if (isProtectedRoute(req)) {
36+
* // Route is protected
37+
* }
38+
* ```
39+
*/
40+
export const createRouteMatcher = (patterns: string[]) => {
41+
const regexPatterns = patterns.map(pattern => {
42+
// Convert glob-like patterns to regex
43+
const regexPattern = pattern
44+
.replace(/\./g, '\\.') // Escape dots
45+
.replace(/\*/g, '.*') // Convert * to .*
46+
.replace(/\(\.\*\)/g, '(.*)'); // Handle explicit (.*) patterns
47+
48+
return new RegExp(`^${regexPattern}$`);
49+
});
50+
51+
return (req: NextRequest): boolean => {
52+
const pathname = req.nextUrl.pathname;
53+
return regexPatterns.some(regex => regex.test(pathname));
54+
};
55+
};

0 commit comments

Comments
 (0)