workos / authkit-nextjs

The WorkOS library for Next.js provides convenient helpers for authentication and session management using WorkOS & AuthKit with Next.js.
MIT License
58 stars 14 forks source link

How to compose `authKitMiddleware` with custom middleware? #47

Open kevinmitch14 opened 4 months ago

kevinmitch14 commented 4 months ago

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!

PaulAsjes commented 4 months ago

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 commented 4 months ago

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 commented 4 months ago

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 commented 4 months ago

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 commented 3 weeks ago

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 commented 3 weeks ago

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 commented 3 weeks ago

@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 commented 3 weeks ago

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 commented 2 weeks ago

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 commented 2 weeks ago

"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 commented 2 weeks ago

"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 commented 2 weeks ago

"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 commented 1 week ago

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*'],
};