NextJS cannot modify access/refresh token cookies
Asked Answered
R

1

6

I am using in my NextJS application Next-Auth for authentication to call a separate BE that gives me an access and refresh token. What I am trying to do with that tokens is to store them as cookies and use Axios interceptors to refresh the tokens when they are expired, I tried many ways to update the tokens but not successful.

What I have tried:

  • call the cookies().set function in a server action but I always get "cookies can be modified from server action"
  • added 2 api endpoints where I am trying to expire and save new tokens, they return successful but not result

The Axios instance is used in all my pages to fetch data. What I am trying to do with this is to always try to refresh tokens and if it doesnt work, to just log the user out.

What could I miss? (using app router)

Thank you in advance for any help!

options.tsx

import CredentialsProvider from 'next-auth/providers/credentials';
import { login } from '@/foundation/Authentication/OAuth2';
import Axios from '@/foundation/Axios';
import { JWT } from 'next-auth/jwt';
import { DefaultUser, Session } from 'next-auth';
import { User } from '@/foundation/Models/User';
import { cookies } from 'next/headers';

export const options = {
  providers: [
    CredentialsProvider({
      name: 'Credentials',
      credentials: {
        username: { label: 'Username', type: 'text' },
        password: { label: 'Password', type: 'password' }
      },
      async authorize(credentials): Promise<DefaultUser | null> {
        if (!credentials?.username || !credentials?.password) {
          return null;
        }

        try {

          const tokens = await login(credentials.username, credentials.password);
          cookies().set('accessToken', tokens.accessToken);
          cookies().set('refreshToken', tokens.refreshToken);

          const {
            data: { data }
          } = await Axios.get('/me');

          return data;
        } catch (err) {
          return null;
        }
      }
    })
  ],
  pages: {
    signIn: '/login'
  },
  callbacks: {
    async jwt({ token, user }: { token: JWT; user: User }): Promise<JWT> {
      if (user) {
        token.id = user.id;
        token.firstName = user.first_name;
        token.lastName = user.last_name;
        token.role = user.role;
      }

      return token;
    },
    async session({
      session,
      token
    }: {
      session: Session;
      token: JWT;
    }): Promise<Session> {
      session.user.id = token.id;
      session.user.first_name = token.firstName;
      session.user.last_name = token.lastName;
      session.user.role = token.role;
      session.user.name = `${token.firstName} ${token.lastName}`;

      return session;
    }
  }
};

Login method:

export const login = async (username: string, password: string) => {
  try {
    const token = await auth.owner.getToken(username, password);

    const tokens = {
      accessToken: token.accessToken,
      refreshToken: token.refreshToken
    };
    // await setTokens(tokens.accessToken, tokens.refreshToken);

    return tokens;
  } catch (e) {
    if (!AxiosStatic.isAxiosError(e)) {
      throw e;
    }

    if (e.status !== 401) {
      throw e;
    }

    return false;
  }
};

Axios instance

import axios, { CancelToken } from 'axios';
import { isEmpty } from 'lodash';
import { getTokens, destroyCookies } from '@/foundation/Storage';
import { refresh } from '@/foundation/Authentication/OAuth2';
import { apiUrl } from '@/utils/Constants';
import { signOut } from 'next-auth/react';

const instance = axios.create({
  baseURL: apiUrl,
  headers: {
    'content-type': 'application/json'
  },
  responseType: 'json'
});

instance.interceptors.request.use(
  async (config) => {
    // Axios bug: this ensures content-type is always set
    if (!config.data) {
      config.data = {};
    }

    let tokens = getTokens();

    if (isEmpty(tokens.accessToken)) {
      try {
        await refresh();

        // Cancel the me call, after refreshing tokens
        if (config.url === '/me') {
          config.cancelToken = new CancelToken((cancel) =>
            cancel('Cancel repeated request')
          );
        }
      } catch (errInner) {
        config.cancelToken = new CancelToken((cancel) =>
          cancel('failed to refresh tokens')
        );
        destroyCookies();
      }

      tokens = getTokens();

      if (isEmpty(tokens.accessToken)) {
        return config;
      }
    }

    config.headers['authorization'] = `Bearer ${tokens.accessToken}`;

    return config;
  },
  (err) => Promise.reject(err)
);

instance.interceptors.response.use(
  (response) => response,
  async (err) => {
    const orgRequest = err.config;

    // Only attempt to retry 401's that hasn't been retried before
    if (err.response.status === 401 && !orgRequest._retry) {
      orgRequest._retry = true;

      let tokens = getTokens();

      if (isEmpty(tokens.refreshToken)) {
        return Promise.reject(err);
      }

      try {
        await refresh();

        // Cancel the me call, after refreshing tokens
        if (orgRequest.url === '/me') {
          orgRequest.cancelToken = new CancelToken((cancel) =>
            cancel('Cancel repeated request')
          );
        }
      } catch (errInner) {
        destroyCookies();
        return Promise.reject(err);
      }

      // Retry the request, with new tokens
      return instance(orgRequest);
    }

    await signOut();

    return Promise.reject(err);
  }
);

export default instance;

Delete token example

'use server';
import { NextResponse } from 'next/server';

export async function DELETE() {
  const response = new NextResponse(JSON.stringify({}), {
    status: 200,
    headers: {
      'Content-Type': 'application/json'
    },
  });

  response.cookies.set('accessToken', '', { path: '/', expires: new Date(0) });
  response.cookies.set('refreshToken', '', { path: '/', expires: new Date(0) });

  return response;
}
Rigsby answered 29/11, 2023 at 15:31 Comment(3)
you should create a minimalist project. there are too many things to be checkedHowler
Take a look at: github.com/vercel/next.js/issues/51875Frydman
In Next.js, modifying cookies directly from server actions (API routes) can be problematic due to Next.js's Server Components architecture. Instead, consider setting cookies in the client-side after receiving the tokens from your API. You might use a combination of fetch or axios to call your API and then set cookies on the client side using document.cookie or a client-side cookie management library.Frown
I
0

I had to do something very similar. Marking a file 'use server' doesn't make them server actions - I was getting "cookies can only be modified in a server action or route handler"

First, mark the file with the server actions 'use server', Then if you want to use it from

  • a server component, you'll have to make the function with 'use server'
  • client component, you can just import and use it directly

Read about server actions here: https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations

This is what worked for me

set cookies with next-auth in the authorize function:

const authResponse = await axios.post(
                `${process.env.API_BASEURL}/auth/login`, {
                    "email": credentials.email,
                    "password": credentials.password
                })

            const authCookies = authResponse.headers['set-cookie']
            await setCookies(authCookies)

setCookies

export const setCookies = async (authCookies: string[] | undefined) => {
    'use server'
    if (authCookies && authCookies.length > 0) {
        authCookies.forEach(cookie => {
            const parsedCookie = parse(cookie)
            const [cookieName, cookieValue] = Object.entries(parsedCookie)[0]

            cookies().set({
                name: cookieName,
                value: cookieValue,
                httpOnly: true,
                maxAge: parseInt(parsedCookie["Max-Age"]),
                path: parsedCookie.path,
                sameSite: 'none',
                expires: new Date(parsedCookie.expires),
                secure: true,
            })
        })
    }
}

axios instance (I only used request interceptor)

'use server'
import axios from "axios";
import {cookies} from "next/headers";
import {refreshServer} from "@/services/refresh";


const axiosInstance = axios.create({
    baseURL: process.env.API_BASEURL || process.env.NEXT_PUBLIC_API_BASEURL,
    withCredentials: true,
    headers: {
        'Content-Type': 'application/json',
    }
})

axiosInstance.interceptors.request.use( async (config)=> {
    const accessToken = cookies().get('access_token')?.value || ''
    if(!accessToken) {
        await refresh()
        const newAccessToken = cookies().get('access_token')?.value || ''
        config.headers.Cookie = `access_token=${newAccessToken}`
    } else {
        config.headers.Cookie = `access_token=${accessToken}`
    }
    return config
}, (error)=> {
    return Promise.reject(error)
} )

export default axiosInstance

refresh, signInSA is just the next-auth signin but I make it a server action

'use server'
import axios from "axios";
import {cookies} from "next/headers";
import {setCookies} from "@/utils/set-cookies";
import {signInSA} from "@/utils/auth-helper";


export const refresh = async () => {
    const refreshToken = cookies().get('refresh_token')?.value || ''

    try {
        const refreshResponse = await axios.post(`${process.env.API_BASEURL}/auth/refresh`, {}, {
            headers: {
                'Content-Type': 'application/json',
                Cookie: `refresh_token=${refreshToken}`
            },
            withCredentials: true
        })
        await setCookies(refreshResponse.headers['set-cookie'])
    } catch (e) {
        if (axios.isAxiosError(e)) {
            if(e.response?.status ===401) {
                // refresh token expired
                await signInSA()
            }
        }
        throw e
    }
}
Iguanodon answered 30/9 at 15:33 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.