Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How to compose authKitMiddleware with custom middleware? #47

Closed
kevinmitch14 opened this issue May 24, 2024 · 24 comments · Fixed by #164
Closed

How to compose authKitMiddleware with custom middleware? #47

kevinmitch14 opened this issue May 24, 2024 · 24 comments · Fixed by #164

Comments

@kevinmitch14
Copy link

kevinmitch14 commented May 24, 2024

Hey, we are trying to implement AuthKit in our multi-tenant nextjs app. We are having trouble composing the authKitMiddleware with other middleware operations mainly rewrites.

What I am currently trying is something like the following: In the debug logs, it is displaying "Unauthenticated user on protected route, redirecting to AuthKit", but there is no redirect occurring. Any idea on how to get this working with more than just the example from the docs?

// Example from docs
export default authkitMiddleware();
export async function middleware(request: NextRequest, event: NextFetchEvent) {
  const url = request.nextUrl.clone();
  const host =
    request.headers.get("x-forwarded-host") ?? request.headers.get("host");


  let subdomain = getSubdomain(host);

  const path = url.pathname;
  if (SAFE_DOMAINS.includes(subdomain)) {
    return NextResponse.next();
  }

  request.nextUrl.pathname = destinationUrl.pathname;

  await authkitMiddleware({
    debug: true,
    middlewareAuth: {
      enabled: true,
      unauthenticatedPaths: [],
    },
  })(request, event);

  return NextResponse.rewrite(request.nextUrl);
}

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

Note, this is not just for rewrites, it would be good to understand how to use this a custom middleware!

@kevinmitch14 kevinmitch14 changed the title How to compose authKitMiddleware with rewrites? How to compose authKitMiddleware with custom middleware? May 24, 2024
@PaulAsjes
Copy link
Contributor

I suspect the problem here is the line

return NextResponse.rewrite(request.nextUrl);

In your configuration, authkit-nextjs will attempt to redirect the user if not authenticated, as seen here:
https://github.com/workos/authkit-nextjs/blob/main/src/session.ts#L46

I think that before the redirect can happen (which is what authkitMiddleware is returning) you're rewriting the NextResponse to send the user to request.nextUrl.

If that doesn't do the trick, can you provide an example repo recreating the issue?

@ankitr
Copy link

ankitr commented Jun 1, 2024

Hi! We're also having this issue — we're looking at using AuthKit, but also need to keep our existing Content Security Policy middleware. The existing authkitMiddleware interface is too high-level, since we also need to update the NextResponse with our own custom headers and footers at some paths. Here's a simple repro of what we're trying to accomplish:

https://github.com/ankitr/next-authkit-example/blob/9556186aae226b554a69c2a13e48c67baf7b3fdb/src/middleware.ts

Please let me know what the best approach is here. Thanks!

@PaulAsjes
Copy link
Contributor

Hey @ankitr, I think you should be able to do this (adding custom headers to the request) by passing in a new request to the AuthKit middleware:

export default async function middleware(request: NextRequest) {
  const newRequestHeaders = new Headers(request.headers);

  newRequestHeaders.set("x-custom", "foo");

  const newRequest = NextResponse.next({
    request: { headers: newRequestHeaders },
  });

  return authkitMiddleware()(newRequest);
}

Bear in mind that you need to be on Next v13 for the above to work. Let me know if that helps!

@kevinmitch14
Copy link
Author

kevinmitch14 commented Jun 3, 2024

Hey @PaulAsjes haven't got a chance to look into this more but when I have time I will provide more info. Using the node package instead for now.

For a bit more context, we have a multi-tenant app where each tenant is housed on a subdomain, so we are using middleware to manage that.

This is the setup with node client in middleware. (which is working as expected)

export async function middleware(request: NextRequest) {
  const host =
    request.headers.get("x-forwarded-host") ?? request.headers.get("host");
  let subdomain = getSubdomain(host);

  const url = request.nextUrl.clone();
  const path = url.pathname;

  if (SAFE_AUTH_DOMAINS.includes(subdomain)) {
    return NextResponse.next();
  }


  const workosCookie = request.cookies.get(WORK_OS_COOKIE_NAME);
  const session = await getSessionFromCookie(workosCookie?.value);
  const { authInitFlowUrl } = getAuthConfig(request);

  if (!session) {
    return NextResponse.redirect(authInitFlowUrl);
  }

  
  request.nextUrl.pathname = `/${subdomain}/publisher${path}`;
  const response = NextResponse.rewrite(request.nextUrl);

  const hasValidSession = await verifyAccessToken(session.accessToken);

  if (!hasValidSession) {
    try {
      const { accessToken, refreshToken } =
        await workosClient.userManagement.authenticateWithRefreshToken({
          clientId: process.env.WORKOS_CLIENT_ID,
          refreshToken: session.refreshToken,
        });

      const encryptedSession = await sealData(
        {
          accessToken,
          refreshToken,
          user: session.user,
          impersonator: session.impersonator,
        },
        { password: process.env.WORKOS_COOKIE_PASSWORD },
      );

      response.cookies.set({
        name: WORK_OS_COOKIE_NAME,
        value: encryptedSession,
        httpOnly: true,
        path: "/",
        secure: process.env.NODE_ENV === "production",
      });
    } catch (e) {
      response.cookies.delete(WORK_OS_COOKIE_NAME);
      return NextResponse.redirect(authInitFlowUrl);
    }
  }

  return response;
}
export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

@mattbf
Copy link

mattbf commented Sep 13, 2024

Also having this issue building a multi-tenant app @PaulAsjes. I want to perform some logic on a subdomain before requiring authentication on the main app domain / dashboard.

I can get the response of authkit to come after this logic, but then if I try to use any other functions inside the app I get the error:

Error: You are calling `some authkit function` on a path that isn’t covered by the AuthKit middleware. Make sure it is running on all paths you are calling `getUser` from by updating your middleware config in `middleware.(js|ts)`.

Any guidance on this?

@josh-respectx
Copy link

I was hitting this issue myself, the solution I found is that you'll want to perform the WorkOS auth middleware last and make sure to return it's response. Not returning the response from the authkitMiddleware function is what causes the error you mentioned.

Here's a simplified example of how I'm combining it with a rate limit check beforehand:

import { authkitMiddleware } from '@workos-inc/authkit-nextjs';
import { ratelimit } from 'lib/upstash/rateLimiter';
import { NextResponse } from 'next/server';

async function authMiddleware(request) {
  const response = await authkitMiddleware({
    middlewareAuth: {
      // Enable the middleware on all routes by default
      enabled: true,
      // Allow logged out users to view these paths
      unauthenticatedPaths: [],
    },
  })(request);

  return response;
}

async function rateLimitMiddleware(request, context) {
  const ip = request.ip ?? '127.0.0.1';
  const { success } = await ratelimit.limit(ip);

  if (!success) {
    // Return a 429 status code when the rate limit is exceeded
    console.warn(`Rate limit exceeded for IP: ${ip}`);
    return NextResponse.json('You have exceeded the rate limit.', {
      status: 429,
    });
  }

  return null; // Return null to indicate that the request can proceed
}

export default async function middleware(request, context) {
  try {
    // Check the rate limit first
    const rateLimitResponse = await rateLimitMiddleware(request, context);
    // Return the rate limit response if it is not null
    // This will stop the middleware chain and return the response
    if (rateLimitResponse) {
      return rateLimitResponse;
    }

    // Continue with the auth middleware when rate limit is not exceeded
    // This will check if the user is authenticated
    return authMiddleware(request);
  } catch (error) {
    console.error('Error in middleware:', error);
    return NextResponse.json('Internal Server Error', { status: 500 });
  }
}

export const config = {
  // Don't match on static files, images and the favicon
  matcher: ['/((?!_next/static|_next/image|favicon.ico)'],
};

@mattbf
Copy link

mattbf commented Sep 13, 2024

@josh-respectx thanks, this makes sense and I can make that work, but what if you have two host patterns, one that is public (no authentication) and one that is private (requires authentication)?

How can you call authKitMiddleware only on the dashboard routes while still having logic to rewrite the requests?

My desired logic:

import { authkitMiddleware } from '@workos-inc/authkit-nextjs';
import { ratelimit } from 'lib/upstash/rateLimiter';
import { NextResponse } from 'next/server';

async function authMiddleware(request) {
  const response = await authkitMiddleware({
    middlewareAuth: {
      // Enable the middleware on all routes by default
      enabled: true,
      // Allow logged out users to view these paths
      unauthenticatedPaths: [],
    },
  })(request);

  return response;
}

export default async function middleware(request, context) {
   // get the host
   let host = req.headers.get("host")
   if(host === `app.myapp.com`){
      // this is the dashboard, check auth
      const authRes = await authMiddleware(req);
      if (authRes?.ok) {
        // Rewrite dashboard requests
        return NextResponse.rewrite(new URL(`/app${path === "/" ? "" : path}`, req.url));
      } else {
        console.log("User not logged in");
        return authRes;
      }
   } else {
     // this is a public page, just continue as usual
     return NextResponse.next();
   }
}

export const config = {
  // Don't match on static files, images and the favicon
  matcher: ['/((?!_next/static|_next/image|favicon.ico)'],
};

@josh-respectx
Copy link

josh-respectx commented Sep 13, 2024

What's the reasoning for the multiple host patterns? Is it the same application that is served from multiple subdomains or domains?

Edit: Ah wait, I see what you're doing with the rewrite. Without actually getting in there and testing this myself, my guess is that you might still want to run the authMiddleware on all requests, but adjust it's unauthenticatedPaths matcher to a regex pattern with a negative lookahead like:

// Allow logged out users to view all paths except those starting with '/app'
unauthenticatedPaths: [/^(?!\/app).*$/],

This will allow you to always return the authRes response, regardless of route, which should help avoid the error you mentioned above.

@mattbf
Copy link

mattbf commented Sep 13, 2024

What's the reasoning for the multiple host patterns? Is it the same application that is served from multiple subdomains or domains?

Edit: Ah wait, I see what you're doing with the rewrite. Without actually getting in there and testing this myself, my guess is that you might still want to run the authMiddleware on all requests, but adjust it's unauthenticatedPaths matcher to a regex pattern with a negative lookahead like:

// Allow logged out users to view all paths except those starting with '/app'
unauthenticatedPaths: [/^(?!\/app).*$/],

This will allow you to always return the authRes response, regardless of route, which should help avoid the error you mentioned above.

This is very clever, I had hopes.. but now I'm getting the error "Error: Error parsing routes for middleware auth. Reason: Capturing groups are not allowed at 2".

@PaulAsjes any guidance or ideas here?

Again to state clearly what I'm trying to do:

  1. I have a multi-tentant app which will have requests from subdomains or custom domains
  2. I ONLY need auth on the dashboard (/app route) app, NOT the public pages (e.g subdomain.custom.com/)

It seems as though it is impossible to configure authkitmiddleware to work with this.

@mattbf
Copy link

mattbf commented Sep 13, 2024

"Error: Error parsing routes for middleware auth. Reason: Capturing groups are not allowed at 2".

I guess what is the alternative here? How can we perform rewrites while also running the request through authkit?

@PaulAsjes
Copy link
Contributor

"Error: Error parsing routes for middleware auth. Reason: Capturing groups are not allowed at 2".

That looks like a RegExp issue. We could go down that particular rabbit hole, but I think the real solution here is that you likely don't want to use middlewareAuth mode for your use case. That mode is for if you want everything to be protected barring some exceptions. In your case it sounds like you want the opposite where everything can be viewed whilst logged out except for /app routes.

In which case I think the easiest solution is to just specify that directly:

import { authkitMiddleware } from '@workos-inc/authkit-nextjs';
import { ratelimit } from 'lib/upstash/rateLimiter';
import { NextResponse } from 'next/server';

export default async function middleware(request, context) {
   // get the host
   let host = req.headers.get("host")
   if(host === `app.myapp.com`){
      // this is the dashboard, check auth
      const authRes = await authkitMiddleware()(req);
      if (authRes?.ok) {
        // Rewrite dashboard requests
        return NextResponse.rewrite(new URL(`/app${path === "/" ? "" : path}`, req.url));
      } else {
        console.log("User not logged in");
        return authRes;
      }
   } else {
     // this is a public page, just continue as usual
     return NextResponse.next();
   }
}

export const config = {
  // Only match on /app routes
  matcher: ['/app/*'],
};

@mattbf
Copy link

mattbf commented Sep 17, 2024

"Error: Error parsing routes for middleware auth. Reason: Capturing groups are not allowed at 2".

That looks like a RegExp issue. We could go down that particular rabbit hole, but I think the real solution here is that you likely don't want to use middlewareAuth mode for your use case. That mode is for if you want everything to be protected barring some exceptions. In your case it sounds like you want the opposite where everything can be viewed whilst logged out except for /app routes.

In which case I think the easiest solution is to just specify that directly:

import { authkitMiddleware } from '@workos-inc/authkit-nextjs';
import { ratelimit } from 'lib/upstash/rateLimiter';
import { NextResponse } from 'next/server';

export default async function middleware(request, context) {
   // get the host
   let host = req.headers.get("host")
   if(host === `app.myapp.com`){
      // this is the dashboard, check auth
      const authRes = await authkitMiddleware()(req);
      if (authRes?.ok) {
        // Rewrite dashboard requests
        return NextResponse.rewrite(new URL(`/app${path === "/" ? "" : path}`, req.url));
      } else {
        console.log("User not logged in");
        return authRes;
      }
   } else {
     // this is a public page, just continue as usual
     return NextResponse.next();
   }
}

export const config = {
  // Only match on /app routes
  matcher: ['/app/*'],
};

@PaulAsjes this logic works as expected but then you get errors like

Error: You are calling `some authkit function` on a path that isn’t covered by the AuthKit middleware. Make sure it is running on all paths you are calling `getUser` from by updating your middleware config in `middleware.(js|ts)`.

I believe because that rewrite isn't returning the authkit response.

Looks like the only option is to use the nodejs package instead.

@PaulAsjes
Copy link
Contributor

I think the issue here is with the RegExp in your matcher. To match all routes on /app you need slightly different syntax according to the Next.js docs:

export const config = {
  // Only match on /app routes
  matcher: ['/app/:path*'],
};

@naikaayush
Copy link

@kevinmitch14 were you able to come up with a solution for composing the middleware with subdomain logic and authkit?

@mattbf
Copy link

mattbf commented Oct 18, 2024

@naikaayush I had to do the same thing that Kevin did: basically rewrite authkit yourself in a server using the node client.
Checks for the session / token, validating, refreshing, and returning the redirect URL when necessary. Then using this inside of the nextjs middleware as a custom function.

WorkOS nodejs docs are pretty good and cover most of this.

Unfortunately it looks like you can't use AuthKit for customized middleware, you have to roll your own Authkit...

@kevinmitch14
Copy link
Author

@naikaayush Yeah I ended up using the node client, which gives a lot more flexibility for more custom use-cases.

@naikaayush
Copy link

@kevinmitch14 thanks for that, so the node client was used in server components, and not the middleware I'm assuming, and what about the client components, did you just end up passing the user object to the client components from the server components?

@kevinmitch14
Copy link
Author

I'm using it in middleware too, I believe it is 'edge-compatible'. And yeah you can pass data to client components.

@PaulAsjes
Copy link
Contributor

I'm going to look into making the middleware easier to work with for custom middleware. Does anyone have any strong feelings on what sort of shape this would take? I'm initially leaning towards a pattern of passing in a function, e.g.

export default authkitMiddleware({
  onAuth: (request, event) => {
    // Run custom logic after the middleware has done its auth checks
  }
});

@mattbf
Copy link

mattbf commented Oct 21, 2024

@PaulAsjes it would be much more composable if we could get a response from authKit and then decide what to do with that response.

For example, using your initial proposal, we would be limited in how we compose our middleware. This proposal requires authkit to be run (and run first) on all routes that the middleware is run on.

export default authkitMiddleware({
  onAuth: (request, event) => {
    // this requires the authKit middleware to run on every request no matter what
  }
});

Whereas if we were able to compose authkit to get a response we could basically perform any logic we wanted (as far as I can imagine):

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
 
export function middleware(request: NextRequest) {
  // we can perform any logic before or after authkit
  if(req.url){
   // whatever we want here
   // multi-tenant apps, adding custom headers, etc.
  }
  
  const authResponse = authKitMiddleware(req);
  const session = authResponse.session;

  if(pathname === "/protected-route" && !session){
    return authResponse.redirectURL
  } else {
   // Another opportunity to perform any custom logic based on granular scenarios + authkit responses
  }
  
  return NextResponse.redirect(new URL('/home', request.url))
}
 
// we can properly customize where the middleware runs
export const config = {
  matcher: '/about/:path*',
}
});

Allow us to compose authkit inside of our nextjs middleware mimics classic middleware design, instead of forcing us to perform logic inside of the middleware that authkit provides.

@KNWR
Copy link
Contributor

KNWR commented Oct 22, 2024

Agree with mattbf. Clerkjs does something similar for their next package (example).

If it helps others, I have a workaround for getting route rewrites working. I'm doing this in the next.config.js file while keeping authkit-nextjs in the middleware file. I think this would also work for setting headers, etc. Their documentation is at https://vercel.com/guides/can-i-redirect-from-a-subdomain-to-a-subpath

I have an /app/[domain] folder, so subdomain.rootdomain.extension:3000 maps to /app/[domain]/page.tsx (I'm specifying the port as this is on local).

/** @type {import('next').NextConfig} */
const nextConfig = {
rewrites() {
  return {
    beforeFiles: [
      {
        source: '/:path((?!static|_next|favicon\\.ico).*)',
        has: [
          {
            type: 'host',
            value: '(?<subdomain>(?!api\\b)[^.]+)\\.rootdomain\\.extension(?::3000)?',
          },
        ],

        destination: '/:subdomain/:path*',
      },
    ],
  }
  },
};

export default nextConfig;

@PaulAsjes
Copy link
Contributor

Hi folks, I have a PR up that should hopefully fix this. I decided to go with @mattbf's suggestion and stick to classic middleware design. Here's an example of what this would look like:

export default async function middleware(request: NextRequest) {
  // Perform logic before or after AuthKit
  
  // Auth object contains the session, response headers and an auhorization URL in the case that the session isn't valid
  // This method will automatically handle setting the cookie and refreshing the session
  const auth = await authkit(request, {
    debug: true,
  });

  // Control of what to do when there's no session on a protected route is left to the developer
  if (request.url.includes("/account") && !auth.session.user) {
    console.log("No session on protected path");
    return NextResponse.redirect(auth.authorizationUrl);
    
    // Alternatively you could redirect to your own login page, for example if you want to use your own UI instead of hosted AuthKit
    return NextResponse.redirect("/login");
  }

  // Headers need to be included in every non-redirect response to ensure that `withAuth` works as expected
  return NextResponse.next({
    headers: auth.headers,
  });
}

// Match against the pages
export const config = { matcher: ["/", "/account/:path*", "/api/:path*"] };

Please let me know if you feel that this is missing something or if you have any other feedback!

@mattbf
Copy link

mattbf commented Jan 9, 2025

@PaulAsjes amazing thank you! This is looking great. No major comments or concerns right now, just two questions:

  1. Will this work as well?
return NextResponse.rewrite(url, { request: { headers: auth.headers } });
  1. What happens if we redirect, rewrite, or return before running the authkit middleware? Will routes that use authkit not work because those headers have not been passed along?

Eager to try this out!

@PaulAsjes
Copy link
Contributor

  1. Will this work as well?
return NextResponse.rewrite(url, { request: { headers: auth.headers } });

Seems to work just fine in my testing!

  1. What happens if we redirect, rewrite, or return before running the authkit middleware? Will routes that use authkit not work because those headers have not been passed along?

Using any of the auth methods like withAuth in your components won't work unless the headers are present. So if you return before running the AuthKit middleware you'll get an error.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging a pull request may close this issue.

7 participants