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
486 stars 42 forks source link

next-intl middleware #169

Closed seanaguinaga closed 2 months ago

seanaguinaga commented 5 months ago

According to this:

https://next-firebase-auth-edge-docs.vercel.app/docs/usage/middleware#usage-with-next-intl-and-other-middlewares

You don't need to do anything but make it look like that?

import { NextRequest } from "next/server";
import createIntlMiddleware from "next-intl/middleware";
import { authMiddleware } from "next-firebase-auth-edge";

const intlMiddleware = createIntlMiddleware({
  locales: ["en", "pl"],
  defaultLocale: "en",
});

export async function middleware(request: NextRequest) {
  return authMiddleware(request, {
    // ...
    handleValidToken: async (tokens) => {
      return intlMiddleware(request);
    },
    handleInvalidToken: async (reason) => {
      return intlMiddleware(request);
    },
    handleError: async (error) => {
      return intlMiddleware(request);
    },
  });
}

I am confused as to how you would make that work with the example middleware:

import { authConfig } from '@right-lane/auth/edge';
import { authMiddleware, redirectToLogin } from 'next-firebase-auth-edge';
import { NextRequest, NextResponse } from 'next/server';

import createMiddleware from 'next-intl/middleware';

const intlMiddleware =  createMiddleware({
  // A list of all locales that are supported
  locales: ['en'],

  // Used when no locale matches
  defaultLocale: 'en',
});

const PUBLIC_PATHS = ['/', '/register', '/login', '/reset-password'];

export async function middleware(request: NextRequest) {
  return authMiddleware(request, {
    loginPath: '/api/login',
    logoutPath: '/api/logout',
    apiKey: authConfig.apiKey,
    cookieName: authConfig.cookieName,
    cookieSerializeOptions: authConfig.cookieSerializeOptions,
    cookieSignatureKeys: authConfig.cookieSignatureKeys,
    serviceAccount: authConfig.serviceAccount,
    handleValidToken: async ({ token, decodedToken }, headers) => {
      // Authenticated user should not be able to access  /, /login, /register and /reset-password routes
      if (PUBLIC_PATHS.includes(request.nextUrl.pathname)) {
        return redirectToHome(request);
      }

      return NextResponse.next({
        request: {
          headers,
        },
      });
    },
    handleInvalidToken: async (reason) => {
      // console.info('Missing or malformed credentials', { reason });

      return redirectToLogin(request, {
        path: '/',
        publicPaths: PUBLIC_PATHS,
      });
    },
    handleError: async (error) => {
      console.error('Unhandled authentication error', { error });
      return redirectToLogin(request, {
        path: '/',
        publicPaths: PUBLIC_PATHS,
      });
    },
  });
}

export const config = {
  matcher: [
    '/',
    '/((?!_next|favicon.ico|api|.*\\.).*)',
    '/api/login',
    '/api/logout',
  ],
};

function redirectToHome(request: NextRequest) {
  const url = request.nextUrl.clone();
  url.pathname = '/home';
  url.search = '';
  return NextResponse.redirect(url);
}
seanaguinaga commented 5 months ago

I have used this library before with amplify-auth and next-intl

https://github.com/kj455/next-compose-middleware

not sure how to do that with this though

seanaguinaga commented 5 months ago

https://github.com/z4nr34l/next-easy-middlewares

This one looks better?

seanaguinaga commented 5 months ago

This somewhat works?

import { authConfig } from '@right-lane/auth/edge';
import { authMiddleware } from 'next-firebase-auth-edge';
import { NextRequest, NextResponse } from 'next/server';

const PUBLIC_PATHS = ['/', '/register', '/login', '/reset-password'];

export async function middleware(request: NextRequest) {
  return authMiddleware(request, {
    loginPath: '/api/login',
    logoutPath: '/api/logout',
    apiKey: authConfig.apiKey,
    cookieName: authConfig.cookieName,
    cookieSerializeOptions: authConfig.cookieSerializeOptions,
    cookieSignatureKeys: authConfig.cookieSignatureKeys,
    serviceAccount: authConfig.serviceAccount,
    handleValidToken: async ({ token, decodedToken }, headers) => {
      // console.info('Got a valid token', { token, decodedToken });

      const url = request.nextUrl.clone();
      const locale = url.locale || 'en'; // Default to 'en' if no locale is found
      // Authenticated user should not be able to access  /, /login, /register and /reset-password routes
      if (
        PUBLIC_PATHS.includes(
          request.nextUrl.pathname.replace(`/${locale}`, ''),
        )
      ) {
        return redirectToHome(request);
      }

      return NextResponse.next({
        request: {
          headers,
        },
      });
    },
    handleInvalidToken: async (reason) => {
      // console.info('Missing or malformed credentials', { reason });

      return redirectToLogin(request, {
        path: '/',
        publicPaths: PUBLIC_PATHS,
      });
    },
    handleError: async (error) => {
      console.error('Unhandled authentication error', { error });
      return redirectToLogin(request, {
        path: '/',
        publicPaths: PUBLIC_PATHS,
      });
    },
  });
}

export const config = {
  matcher: [
    '/',
    '/((?!_next|favicon.ico.*\\.).*)',
    '/api/login',
    '/api/logout',
  ],
};

function redirectToHome(request: NextRequest) {
  const url = request.nextUrl.clone();
  const locale = url.locale || 'en'; // Default to 'en' if no locale is found
  url.pathname = '/home';
  url.search = '';
  return NextResponse.redirect(new URL(`/${locale}/home`, url));
}

interface RedirectToLoginOptions {
  path: string;
  publicPaths: string[];
  redirectParamKeyName?: string;
}

export function redirectToLogin(
  request: NextRequest,
  options: RedirectToLoginOptions = {
    path: '/login',
    publicPaths: ['/login'],
  },
) {
  const url = request.nextUrl.clone();
  const locale = url.locale || 'en'; // Default to 'en' if no locale is found
  const redirectKey = options.redirectParamKeyName || 'redirect';

  if (
    options.publicPaths.includes(
      request.nextUrl.pathname.replace(`/${locale}`, ''),
    )
  ) {
    return NextResponse.next();
  }

  url.pathname = options.path;
  url.search = `${redirectKey}=${request.nextUrl.pathname}${url.search}`;
  return NextResponse.redirect(new URL(`/${locale}/login`, url));
}
seanaguinaga commented 5 months ago

Actually just this?

import { authConfig } from '@right-lane/auth/edge';
import { authMiddleware } from 'next-firebase-auth-edge';
import { NextRequest } from 'next/server';

import createIntlMiddleware from 'next-intl/middleware';
import { defaultLocale, localePrefix, locales, pathnames } from './config';

const PUBLIC_PATHS = ['/', '/register', '/login', '/reset-password'];

const intlMiddleware = createIntlMiddleware({
  defaultLocale,
  locales,
  pathnames,
  localePrefix,
});

const publicPathnameRegex = RegExp(
  `^(/(${locales.join('|')}))?(${PUBLIC_PATHS.flatMap((p) =>
    p === '/' ? ['', '/'] : p,
  ).join('|')})/?$`,
  'i',
);

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

  return authMiddleware(request, {
    loginPath: '/api/login',
    logoutPath: '/api/logout',
    apiKey: authConfig.apiKey,
    cookieName: authConfig.cookieName,
    cookieSerializeOptions: authConfig.cookieSerializeOptions,
    cookieSignatureKeys: authConfig.cookieSignatureKeys,
    serviceAccount: authConfig.serviceAccount,
    handleValidToken: async (tokens) => {
      if (isPublicPage) {
        request.nextUrl.pathname = '/home';
      }
      return intlMiddleware(request);
    },
    handleInvalidToken: async (reason) => {
      if (!isPublicPage) {
        request.nextUrl.pathname = '/login';
      }
      return intlMiddleware(request);
    },
    handleError: async (error) => {
      if (!isPublicPage) {
        request.nextUrl.pathname = '/login';
      }
      return intlMiddleware(request);
    },
  });
}

export const config = {
  matcher: [
    // for next-firebase-auth-edge
    '/api/login',
    '/api/logout',
    // Enable a redirect to a matching locale at the root
    '/',
    // Set a cookie to remember the previous locale for
    // all requests that have a locale prefix
    `/${locales.join('|')}/:path*`,
    // Enable redirects that add missing locales
    // (e.g. `/pathnames` -> `/en/pathnames`)
    '/((?!_next|_vercel|.*\\..*).*)',
  ],
};

This is for the app router example that next-intl has which is well-featured

https://next-intl-example-app-router.vercel.app/en

awinogrodzki commented 5 months ago

Hey @seanaguinaga!

Thanks for reporting!

The example you mentioned (https://next-firebase-auth-edge-docs.vercel.app/docs/usage/middleware#usage-with-next-intl-and-other-middlewares) is very simplified version without any redirection mechanism.

You're right. Making next-firebase-auth-edge work with next-intl in more complex scenarios requires some custom, locale-detection logic.

In some face-to-face conversations people also told me it's hard to compose the library with next-international due to Modified Request Headers feature.

I will be re-thinking the API to allow integration with libraries that you've mentioned and more.

Here's how I handle integration with next-intl and next-firebase-auth-edge. It's pretty similar to what you've come up with:

// middleware.ts
import { AUTH_TOKENS_COOKIE_NAME } from '@ensite/auth/lib/consts';
import { cookieOptions, serverFirebaseOptions } from './config';
import { clientFirebaseOptions } from './app/config';
import { authMiddleware } from 'next-firebase-auth-edge';
import { NextRequest, NextResponse } from 'next/server';
import createIntlMiddleware from 'next-intl/middleware';
import { defaultLocale, locales } from '@ensite/i18n/lib/consts';
import { Locale } from '@ensite/domain/lib/locale';

const LOCALE_LIST = ['en', 'pl', 'de'];
const LOCALE_REGEXP = /^\/([a-z]{2})(\/.+)?$/;
const LOCALE_SET = new Set(LOCALE_LIST);

export function extractLocale(pathname: string): Locale | null {
  const match = pathname.match(LOCALE_REGEXP);

  if (!match) {
    return null;
  }

  const locale = match[1];

  if (!locale || !LOCALE_SET.has(locale)) {
    return null;
  }

  return locale as Locale;
}

export function removeLocale(pathname: string, locale: Locale | null) {
  if (!locale) {
    return pathname;
  }

  if (pathname === `/${locale}`) {
    return '/';
  }

  return pathname.replace(`/${locale}/`, '/');
}

export function withoutLocale(pathname: string): string {
  const locale = extractLocale(pathname);

  return removeLocale(pathname, locale);
}

const handleI18nRouting = createIntlMiddleware({
  localePrefix: 'as-needed',
  locales,
  defaultLocale,
});

function redirectToLogin(request: NextRequest) {
  if (withoutLocale(request.nextUrl.pathname) === '/login') {
    return handleI18nRouting(request);
  }

  const url = request.nextUrl.clone();

  url.pathname = `/login`;
  url.searchParams.set('redirect', request.nextUrl.pathname);

  return NextResponse.redirect(url);
}

export async function middleware(request: NextRequest) {
  return authMiddleware(request, {
    loginPath: '/api/login',
    logoutPath: '/api/logout',

    apiKey: clientFirebaseOptions.apiKey,
    cookieName: AUTH_TOKENS_COOKIE_NAME,
    cookieSignatureKeys: cookieOptions.keys,
    cookieSerializeOptions: {
      path: cookieOptions.path,
      httpOnly: cookieOptions.httpOnly,
      secure: cookieOptions.secure,
      sameSite: cookieOptions.sameSite,
      maxAge: cookieOptions.maxAge,
    },
    serviceAccount: serverFirebaseOptions,
    handleValidToken: async ({ decodedToken }) => {
      if (!decodedToken.email_verified) {
        return redirectToLogin(request);
      }

      return handleI18nRouting(request);
    },
    handleInvalidToken: async () => {
      return redirectToLogin(request);
    },
    handleError: async () => {
      return redirectToLogin(request);
    },
  });
}

export const config = {
  matcher: [
    '/',
    '/((?!_next|api/instagram|api/templates|api/revalidate|api/reauth|assets|health-check|favicon.ico|logo.svg).*)',
  ],
};
awinogrodzki commented 5 months ago

@seanaguinaga by the way, you mentioned using https://github.com/kj455/next-compose-middleware with amplify-auth and next-intl. Is there a chance you could share some code examples? It will help me to gather some hints about the approach I should take

saulogt commented 3 months ago

By doing this, the token will be re-checked on the subsequent calls (AFAIK):

    handleValidToken: async (tokens) => {
      if (isPublicPage) {
        request.nextUrl.pathname = '/home';
      }
      return intlMiddleware(request);
    },

Would it be ok to modify the request with the new headers? Maybe something like this?

    handleValidToken: async (tokens, headers) => {
      if (isPublicPage) {
        request.nextUrl.pathname = "/home";
      }
      headers?.forEach((value, key) => {
        request.headers.set(key, value);
      });
      return intlMiddleware(request);
    },
awinogrodzki commented 3 months ago

Hey @saulogt!

By doing this, the token will be re-checked on the subsequent calls (AFAIK):

Is this actually the behavior you're experiencing?

    handleValidToken: async (tokens) => {
      if (isPublicPage) {
        request.nextUrl.pathname = '/home';
      }
      return intlMiddleware(request);
    },

This should work just fine.

      headers?.forEach((value, key) => {
          request.headers.set(key, value);
      });

This is actually done by authMiddleware just before handleValidRequest is called.

I inspected intlMiddleware implementation and when it's returning a response, it automatically decorates request headers (See this link), so you don't need to pass headers by yourself.

To add on that, there is no way to Modify Request Headers other than passing headers directly to NextResponse.next({ request: { headers } }) or NextResponse.rewrite({ request: { headers } }) at the moment

awinogrodzki commented 2 months ago

I will close the issue for now.

Due to Next.js limitation, we cannot set Modified Request Headers on existing response, which requires authMiddleware to be called at the top level, if we want to provide different behaviour depending on user context.

Simply put: If you want to use handleValidToken, handleInvalidToken or handleError, you will have hard time composing the middleware with next-compose-middleware

When Next.js upgrades it's API to allow to update modified request headers on response instance, I will reopen the issue and introduce updates to make it compatible with middleware composers