Skip to content

Commit

Permalink
feat: Support Wildcard Paths in Next SDK Middleware and Support Commo…
Browse files Browse the repository at this point in the history
…nJS (#1009) RELEASE

## Related Issues

Fixes descope/etc#9245

## Related PRs

| branch       | PR         |
| ------------ | ---------- |
| service a PR | Link to PR |
| service b PR | Link to PR |

## Description

Be able to support wildcard paths in Next middleware.

Also we are updating the package.json exports to support both ES Modules
(import) and CommonJS (require).

## Must

- [ ] Tests
- [ ] Documentation (if applicable)

---------

Signed-off-by: Kevin J Gao <[email protected]>
  • Loading branch information
gaokevin1 authored Feb 26, 2025
1 parent f618901 commit fc36903
Show file tree
Hide file tree
Showing 3 changed files with 82 additions and 6 deletions.
3 changes: 2 additions & 1 deletion packages/sdks/nextjs-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,8 @@ export default authMiddleware({
// NOTE: In case it contains query parameters that exist in the original URL, they will override the original query parameters. e.g. if the original URL is /page?param1=1&param2=2 and the redirect URL is /sign-in?param1=3, the final redirect URL will be /sign-in?param1=3&param2=2
redirectUrl?: string,

// These are the public and private routes in your app. Read more about how to use these below.
// These are the public and private routes in your app. You can also use wildcards (e.g. /api/*) with routes as well in these definitions.
// Read more about how to use these below.
publicRoutes?: string[],
privateRoutes?: string[]
// If you having privateRoutes and publicRoutes defined at the same time, privateRoutes will be ignored.
Expand Down
20 changes: 15 additions & 5 deletions packages/sdks/nextjs-sdk/src/server/authMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,23 @@ const getSessionJwt = (req: NextRequest): string | undefined => {
return undefined;
};

const matchWildcardRoute = (route: string, path: string) => {
let regexPattern = route.replace(/[.+?^${}()|[\]\\]/g, '\\$&');

// Convert wildcard (*) to match path segments only
regexPattern = regexPattern.replace(/\*/g, '[^/]*');
const regex = new RegExp(`^${regexPattern}$`);

return regex.test(path);
};

const isPublicRoute = (req: NextRequest, options: MiddlewareOptions) => {
// Ensure publicRoutes and privateRoutes are arrays, defaulting to empty arrays if not defined
const publicRoutes = options.publicRoutes || [];
const privateRoutes = options.privateRoutes || [];
const { publicRoutes = [], privateRoutes = [] } = options;
const { pathname } = req.nextUrl;

const isDefaultPublicRoute = Object.values(DEFAULT_PUBLIC_ROUTES).includes(
req.nextUrl.pathname
pathname
);

if (publicRoutes.length > 0) {
Expand All @@ -59,12 +69,12 @@ const isPublicRoute = (req: NextRequest, options: MiddlewareOptions) => {
'Both publicRoutes and privateRoutes are defined. Ignoring privateRoutes.'
);
}
return isDefaultPublicRoute || publicRoutes.includes(req.nextUrl.pathname);
return isDefaultPublicRoute || publicRoutes.some((route) => matchWildcardRoute(route, pathname))
}

if (privateRoutes.length > 0) {
return (
isDefaultPublicRoute || !privateRoutes.includes(req.nextUrl.pathname)
isDefaultPublicRoute || !privateRoutes.some((route) => matchWildcardRoute(route, pathname))
);
}

Expand Down
65 changes: 65 additions & 0 deletions packages/sdks/nextjs-sdk/test/server/authMiddleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,71 @@ describe('authMiddleware', () => {
);
});

it('redirects unauthenticated users for private routes matching wildcard patterns', async () => {
mockValidateJwt.mockRejectedValue(new Error('Invalid JWT'));

const middleware = authMiddleware({
privateRoutes: ['/private/*']
});

// Mock request to a route matching the wildcard pattern
const mockReq = createMockNextRequest({ pathname: '/private/dashboard' });

const response = await middleware(mockReq);

// Expect a redirect since the user is unauthenticated
expect(NextResponse.redirect).toHaveBeenCalledWith(expect.anything());
expect(response).toEqual({
pathname: DEFAULT_PUBLIC_ROUTES.signIn
});
});

it('allows authenticated users for private routes matching wildcard patterns', async () => {
const authInfo = {
jwt: 'validJwt',
token: { iss: 'project-1', sub: 'user-123' }
};
mockValidateJwt.mockImplementation(() => authInfo);

const middleware = authMiddleware({
privateRoutes: ['/private/*']
});

const mockReq = createMockNextRequest({
pathname: '/private/settings',
headers: { Authorization: 'Bearer validJwt' }
});

await middleware(mockReq);

// Expect no redirect and that the session header is set
expect(NextResponse.redirect).not.toHaveBeenCalled();
expect(NextResponse.next).toHaveBeenCalled();

const headersArg = (NextResponse.next as any as jest.Mock).mock.lastCall[0]
.request.headers;
expect(headersArg.get('x-descope-session')).toEqual(
Buffer.from(JSON.stringify(authInfo)).toString('base64')
);
});

it('allows unauthenticated users for public routes matching wildcard patterns', async () => {
mockValidateJwt.mockRejectedValue(new Error('Invalid JWT'));

const middleware = authMiddleware({
publicRoutes: ['/public/*']
});

// Mock request to a route matching the wildcard pattern
const mockReq = createMockNextRequest({ pathname: '/public/info' });

await middleware(mockReq);

// Expect no redirect since it's a public route
expect(NextResponse.redirect).not.toHaveBeenCalled();
expect(NextResponse.next).toHaveBeenCalled();
});

it('blocks unauthenticated users and redirects to custom URL', async () => {
mockValidateJwt.mockRejectedValue(new Error('Invalid JWT'));
const customRedirectUrl = '/custom-sign-in';
Expand Down

0 comments on commit fc36903

Please sign in to comment.