awinogrodzki / next-firebase-auth-edge

Next.js Firebase Authentication for Edge and Node.js runtimes. Compatible with latest Next.js features.
https://next-firebase-auth-edge-docs.vercel.app/
MIT License
520 stars 44 forks source link

getTokens() returns undefined in Edge API Route on Next.js 14 #257

Closed zatheg closed 1 month ago

zatheg commented 1 month ago

I'm encountering an issue with next-firebase-auth-edge version 1.7.1 when trying to use getTokens() in an API route handler on Next.js 14. Despite following the documentation here, I'm getting an "Unauthorized" error because getTokens() returns undefined.

Code Snippets:

Here's the relevant part of my code:

// route.ts
import { getTokens } from "next-firebase-auth-edge";
import { cookies } from "next/headers";
import { serverConfig } from "@/lib/firebase/config";

export async function GET(
  request: NextRequest,
  { params }: { params: { threadId: string } }
) {
  try {
    const tokens = await AuthCheck.protect();
    // ...rest of the code
  } catch (error: any) {
    console.error("verifyAssistantAccess", error);
    return NextResponse.json(
      {
        error: "Error verifying assistant access",
        code: "verify_assistant_access_error",
        authorized: false,
      },
      { status: 500 }
    );
  }
}

// auth-check.ts
import { getTokens, Tokens } from "next-firebase-auth-edge";
import { cookies } from "next/headers";
import { serverConfig } from "../firebase/config";

class AuthCheck {
  // Method to fetch tokens from cookies
  static async getTokens(): Promise<Tokens> {
    const tokens = await getTokens(cookies(), serverConfig as any);
    if (!tokens) {
      throw new Error("Unauthorized");
    }
    return tokens;
  }

  // Method to protect routes
  static async protect(): Promise<Tokens> {
    const tokens = await this.getTokens();
    // ...additional checks
    return tokens;
  }
}

export default AuthCheck;

Error Message:

verifyAssistantAccess Error: Unauthorized
    at AuthCheck.getTokens (webpack-internal:///(rsc)/./src/lib/helpers/auth-check.ts:16:19)
    at async AuthCheck.protect (webpack-internal:///(rsc)/./src/lib/helpers/auth-check.ts:34:24)
    at async GET (webpack-internal:///(rsc)/./src/app/api/threads/[threadId]/messages/route.ts:22:24)
// ...rest of the stack trace

Additional Information:

Steps to Reproduce:

  1. Set up a Next.js 14 application with an API route handler.
  2. Install next-firebase-auth-edge version 1.7.1 and configure it as per the documentation.
  3. Implement authentication checks using getTokens() in the API route.
  4. Make a request to the API route with valid authentication cookies using Chrome.
  5. Observe that getTokens() returns undefined and an "Unauthorized" error is thrown.

Expected Behavior:

getTokens() should return the authentication tokens extracted from the cookies, allowing the API route to authenticate the user successfully.

Actual Behavior:

getTokens() returns undefined, causing an "Unauthorized" error to be thrown in the API route handler.

Environment:

Possible Cause:

It seems like getTokens() might not be working as expected in API route handlers in Next.js 14, even though the documentation indicates that it should. The fact that it works in server actions and server components suggests that there might be an issue specific to API routes.

Request for Assistance:

Is this a known issue with next-firebase-auth-edge? Are there any workarounds or fixes available to get getTokens() working in API route handlers in Next.js 14?


Note: Any guidance or assistance would be greatly appreciated.

awinogrodzki commented 1 month ago

Hey @zatheg! Thanks for reporting.

Could you enable debug mode and share the logs here?

zatheg commented 1 month ago

Hi @awinogrodzki ,

Thank you for your prompt response.

I have enabled debug mode as you requested. Here are the logs:

ⓘ next-firebase-auth-edge: getTokens: Tokens successfully extracted from cookies
ⓘ next-firebase-auth-edge: getTokens: Cookies are marked as verified. Skipping verification
ⓘ next-firebase-auth-edge: getTokens: took 0.021ms
ⓘ next-firebase-auth-edge: getTokens: Tokens successfully extracted from cookies
ⓘ next-firebase-auth-edge: getTokens: Cookies are marked as verified. Skipping verification
ⓘ next-firebase-auth-edge: getTokens: took 0.016ms
ⓘ next-firebase-auth-edge: Missing authentication cookies
ⓘ next-firebase-auth-edge: Token is missing or has incorrect formatting. This is expected and usually means that user has not yet logged in
             reason: MISSING_CREDENTIALS
ⓘ next-firebase-auth-edge: getTokens: took 0.001ms
Error getting thread messages Error: Unauthorized
    at AuthCheck.getTokens (webpack-internal:///(rsc)/./src/lib/helpers/auth-check.ts:19:19)
    at async AuthCheck.protect (webpack-internal:///(rsc)/./src/lib/helpers/auth-check.ts:37:24)
    at async GET (webpack-internal:///(rsc)/./src/app/api/threads/[threadId]/messages/route.ts:60:25)
    // ...rest of the stack trace

Additional Information:

awinogrodzki commented 1 month ago

Thanks for the details @zatheg.

That is peculiar indeed. getTokens should never return undefined. It should return null when the token is missing.

Let's try some experiments to pin-point the place where the code breaks.

In API route handler, just before calling getTokens, could you call cookies().get(serverConfig.cookieName) and share the value? You can use https://jwt.io to decode the cookie and obfuscate vulnerable data. I am interested in token structure

zatheg commented 1 month ago

Hi @awinogrodzki ,

Following your instructions, I added a call to cookies().get(serverConfig.cookieName) just before invoking getTokens() in my API route handler. Here are the results:

API Route Handler

In the API route handler, the cookie retrieval returns undefined:

// route.ts
import { getTokens } from "next-firebase-auth-edge";
import { cookies } from "next/headers";
import { serverConfig } from "@/lib/firebase/config";

export async function GET(
  request: NextRequest,
  { params }: { params: { threadId: string } }
) {
  try {
    // Retrieve the cookie value
    const authCookie = cookies().get(serverConfig.cookieName);
    console.log("Auth Cookie in API Route:", authCookie); // Outputs: undefined

    const tokens = await AuthCheck.protect();
    // ...rest of the code
  } catch (error: any) {
    console.error("Error getting thread messages", error);
    return NextResponse.json(
      {
        error: "Error verifying assistant access",
        code: "verify_assistant_access_error",
        authorized: false,
      },
      { status: 500 }
    );
  }
}

Output:

Auth Cookie in API Route: undefined

Server Component

In contrast, within a server component, the cookie is successfully retrieved and contains a JWT token:

Decoded JWT Structure:

I used jwt.io to decode the JWT token obtained from the server component. Here's the obfuscated structure:

{
  "id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...[rest of the token]...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...[rest of the token]..."
}

Thank you for your support!

awinogrodzki commented 1 month ago

Thanks @zatheg!

const authCookie = cookies().get(serverConfig.cookieName);
console.log("Auth Cookie in API Route:", authCookie); // Outputs: undefined

This is interesting. It seems API Route does not have access to cookies. We are getting closer to the resolution.

Could you confirm that serverConfig.cookieName is the same as in server component?

Also, could you share your middleware.ts file?

Last question: is API route called under the same domain as the rest of the app?

zatheg commented 1 month ago

Hi @awinogrodzki ,

Thank you for your continued assistance.

Confirmation of serverConfig.cookieName

Yes, I have confirmed that serverConfig.cookieName is identical in both the server components and the API route handler. This ensures consistency in how the cookie is referenced across different parts of the application.

Middleware Configuration (middleware.ts)

Here is my current middleware.ts file:

// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import {
  authMiddleware,
  redirectToHome,
  redirectToLogin,
} from "next-firebase-auth-edge";
import { clientConfig, serverConfig } from "./lib/firebase/config";

async function getEstablishmentSlugFromEstablishmentId(
  establishmentId: string
): Promise<string> {
  const res = await fetch(
    `${process.env.NEXT_PUBLIC_BASE_URL}/api/establishments/slug/${establishmentId}`,
    {
      method: "GET",
      headers: {
        "Content-Type": "application/json",
      },
    }
  );

  const data = await res.json();

  return data.slug;
}

const PUBLIC_PATHS = [
  "/",
  "/assistants",
  "/assistant",
  "/assistant/*",
  "/assistants/*",
  "/auth",
  "/auth/*",
  "/pricing",
  "/pricing/*",
  "/features",
  "/features/*",
  "/contact",
  "/contact/*",
  "/about",
  "/about/*",
  "/terms",
  "/terms/*",
  "/privacy",
  "/privacy/*",
  "/404",
  "/500",
];

const isPublicPath = (path: string) => {
  const regexPublicPaths = PUBLIC_PATHS.map((publicPath) => {
    if (publicPath.includes("*")) {
      return new RegExp(`^${publicPath.replace("*", ".*")}$`);
    }
    return new RegExp(`^${publicPath}$`);
  });

  return (
    regexPublicPaths.some((regex) => regex.test(path)) ||
    /^\/assistant\/[^/]+$/.test(path)
  );
};

export async function middleware(request: NextRequest) {
  const requestPath = request.nextUrl.pathname;

  return authMiddleware(request, {
    loginPath: "/api/login",
    logoutPath: "/api/logout",
    refreshTokenPath: "/api/refresh-token",
    apiKey: clientConfig.apiKey,
    cookieName: serverConfig.cookieName,
    cookieSignatureKeys: serverConfig.cookieSignatureKeys,
    cookieSerializeOptions: serverConfig.cookieSerializeOptions,
    serviceAccount: serverConfig.serviceAccount,
    handleValidToken: async ({ token, decodedToken }, headers) => {
      if (isPublicPath(requestPath)) {
        if (requestPath.startsWith("/auth")) {
          const url = request.nextUrl.clone();

          // if the user is logged in and tries to access the login page, redirect to the appropriate page
          if (decodedToken.role === "global-admin") {
            url.pathname = "/admin";
            return NextResponse.redirect(url);
          } else {
            //get the slug of the establishment the user is associated with
            const establishmentSlug =
              await getEstablishmentSlugFromEstablishmentId(
                decodedToken.establishmentId as string
              );
            url.pathname = `/establishment/${establishmentSlug}/chat`;
            return NextResponse.redirect(url);
          }
        }

        return NextResponse.next({
          request: {
            headers,
          },
        });
      }
      //Is on a private page
      else {
        const url = request.nextUrl.clone();

        //Global admin have access to all pages so they can continue
        if (decodedToken.role === "global-admin") {
          return NextResponse.next({
            request: {
              headers,
            },
          });
        }

        //get the slug of the establishment the user is associated with
        const establishmentSlug = await getEstablishmentSlugFromEstablishmentId(
          decodedToken.establishmentId as string
        );

        //If the user tries to access an establishment page that is not his, replace the slug with his establishment slug
        if (requestPath.includes("/establishment/")) {
          const establishmentSlugFromPath = requestPath.split("/")[2];
          if (establishmentSlugFromPath !== establishmentSlug) {
            url.pathname = requestPath.replace(
              establishmentSlugFromPath,
              establishmentSlug
            );
            return NextResponse.redirect(url);
          }
        }

        //if a non establishment admin tries to access an establishment dashboard, redirect to the chat page (which is the only page he has access to)
        if (
          requestPath.includes(
            `/establishment/${establishmentSlug}/dashboard`
          ) &&
          decodedToken.role !== "admin"
        ) {
          url.pathname = `/establishment/${establishmentSlug}/chat`;
          return NextResponse.redirect(url);
        }

        //Check if the user has completed the sign up process, if he is the only admin of the establishment, it means he needs to complete the onboarding process instead
        if (
          decodedToken.role !== "global-admin" &&
          !decodedToken.hasCompletedSignUp
        ) {
          console.log("User has not completed sign up process", decodedToken);
          // if the user is not a global admin and has not completed the sign up process, redirect to the complete sign up page
          url.pathname = "/auth/complete-signup";
          return NextResponse.redirect(url);
        }
      }

      return NextResponse.next({
        request: {
          headers,
        },
      });
    },
    handleInvalidToken: async (reason) => {
      if (isPublicPath(requestPath)) {
        return NextResponse.next();
      }

      return redirectToLogin(request, {
        path: "/auth",
        publicPaths: PUBLIC_PATHS,
      });
    },
    handleError: async (_) => {
      if (isPublicPath(requestPath)) {
        return NextResponse.next();
      }

      return redirectToLogin(request, {
        path: "/auth",
        publicPaths: PUBLIC_PATHS,
      });
    },
  });
}

export const config = {
  matcher: [
    "/",
    "/((?!_next|favicon.ico|__/auth|__/firebase|api|.*\\.).*)",
    "/api/login",
    "/api/logout",
    "/api/refresh-token",
  ],
};

API Route Domain Confirmation

Yes, the API route is called under the same domain as the rest of the application. There are no cross-domain requests involved, and all interactions occur within the same origin.

awinogrodzki commented 1 month ago

@zatheg I think I know where the error comes from.

In API route handler, you should use cookies from inside incoming request:

export async function GET(request: NextRequest) {
  const tokens = await getTokens(request.cookies, serverConfig);
}

You're passing a result of calling cookies() imported from next/headers, which is meant to be used in Server Components and Server Actions.

Let me know if that works!

awinogrodzki commented 1 month ago

I apologize, as I might've caused a confusion. I've noticed that the docs about library usage in API route handlers have the same mistake. I will fix the issue immediately!

Thank you for creating an issue!

zatheg commented 1 month ago

Hi again @awinogrodzki ,

Thank you for the suggestion! I updated the API route handler to use request.cookies directly as you mentioned, but I'm still encountering issues.

Here are the details:

  1. API Route Code:

    export async function GET(request: NextRequest, { params }: { params: { threadId: string } }) {
     try {
       console.log("Cookies", request.cookies); // Logs request.cookies
       const tokens = await getTokens(request.cookies, {
         debug: true,
         ...(serverConfig as any),
       });
    
       if (!tokens) {
         console.error("No ID token provided");
         return NextResponse.json(
           {
             error: "You must be authenticated to access this assistant",
             code: "unauthenticated",
             authorized: false,
           },
           { status: 401 }
         );
       }
       // Continue processing...
     } catch (error: any) {
       console.error("Error getting thread messages", error);
       return NextResponse.json(
         {
           error: "Error getting thread messages",
           code: "verify_assistant_access_error",
           authorized: false,
         },
         { status: 500 }
       );
     }
    }
  2. Printed Cookies in the API Route:

    Cookies RequestCookies {
     _parsed: Map(0) {},
     _headers: Headers {
       accept: '*/*',
       'accept-encoding': 'gzip, deflate',
       'accept-language': '*',
       'cache-control': 'no-cache',
       connection: 'keep-alive',
       'content-type': 'application/json',
       host: 'localhost:3000',
       pragma: 'no-cache',
       'sec-fetch-mode': 'cors',
       'user-agent': 'node',
       'x-forwarded-for': '::1',
       'x-forwarded-host': 'localhost:3000',
       'x-forwarded-port': '3000',
       'x-forwarded-proto': 'http'
     }
    }

    When I attempt to log the specific cookie with:

    console.log("Cookies", request.cookies.get(serverConfig.cookieName));

    It prints undefined.

  3. Additional Context:

    • The cookie is set and accessible in other parts of the app (e.g., Server Components and Server Actions), but in the API route, request.cookies.get(serverConfig.cookieName) returns undefined.
    • The headers in the request.cookies object don't seem to contain any actual cookies, just request-related headers like accept, user-agent, etc.

I'm not sure why the cookies are not accessible in the API route, but it seems like the request is not including the cookie in the headers.

awinogrodzki commented 1 month ago

Good morning @zatheg!

In such case, I think the root-cause for the issue might lie within the structure of a code. See this:

https://github.com/vercel/next.js/issues/52209#issuecomment-1741833812

Is your API route called from the client-side (browser), or do you call it from inside the Middleware or Server Component?

awinogrodzki commented 1 month ago

I see that the user-agent of request to API route is node:

'user-agent': 'node',

You could try to forward the cookie header to the API route request, if the preceding request is made from the browser.

Eg. if you call your API route from inside Server Component, you could do something similar to this:

import {headers} from 'next/headers';

await fetch('/api/some-route', ({
  headers: {
    cookie: headers().get('cookie')
  }
}))

I never tested it, but it should work fine unless Next.js is deliberately stripping cookie header on such requests, which I doubt

zatheg commented 1 month ago

Thank you so much for your support. This is a working solution.

awinogrodzki commented 1 month ago

Awesome! Thanks for confirming! I will close the issue as it seems resolved.

Cheers!