cookiesnext.js13

Set-Cookie in Server Action doesn't work with path /auth


My React Next.JS Application calls a REST API External and receives tokens in Cookies for authentication (Access and Refresh Tokens), like this picture below:

https://i.sstatic.net/TMa5rcuJ.png

I use Server Actions to fetch this REST API, maintaining sensitive information such as login and password on the server side.

In my Server Action I set those cookies in my client and send those cookies to each request the REST API in the request header, like this:

const proxyServerCookies = (cookieNames, response) => {
    if (response.headers.has('set-cookie')) {
        const cookieString = response.headers.get('set-cookie');
        const cookieObject = setCookieParser.parse(setCookieParser.splitCookiesString(cookieString), {
            map: true,
        });

        cookieNames.forEach(async (cookieName) => {
            if (cookieObject[cookieName]) {
                const cookie = cookieObject[cookieName];

                await cookies().set(cookieName, cookie.value, {
                    //The problem is right here. If i put the line below the cookie in path /auth, like I received from the REST API, it does not show in the browser client.
                    //path: cookie.path, 
                    path: '/',   
                    maxAge: cookie.maxAge,
                    sameSite: (cookie.sameSite),
                    expires: cookie.expires,
                    secure: cookie.secure,
                    httpOnly: cookie.httpOnly,
                });
            }
        });
    }
}

The problem is that the refreshToken cookie that I receive from the REST API comes with the path /auth and keeping this path the Action Server cannot write to the client, or at least I don't see it in the browser's cookie list.

When I change the path to root / the cookie recording works.

Could anyone tell me why this behavior?


Solution

  • I was a little confused with how cookies work, but my friend @mr mcwolf helped me review the concepts and the solution to the problem follows:

    I make requests to an external REST API that returns two access cookies in the response, an AccessToken in the '/' path and the RefreshToken in the '/auth' path (The address to use the Refresh Token is https://api_domain/auth/refresh-token).

    In my NextJS client, I basically use interceptors to check when the AccessToken has expired and use RefreshToken to update it.

    However, in the first attempt I do not have AccessToken or RefreshToken, making it necessary to use the Login/Password credentials to log into the REST API, which is why I used NextJS's Actions Servers, to leave this sensitive data only on the server side.

    Therefore, I had to manually write and read cookies through the Action Server as requests would be made from them. I simply took the cookies coming from the REST API (AccessToken and RefreshToken) and created a copy of the client's browser, via the Action Server. According to the post code.

    When the client tried to access an address on my website (https://my_domain/app/access), the Server Action was asked to take care of it, sending the request to the REST API. The access token has expired and the Action Server must use the RefreskToken to refresh in the REST API at https://api_domain/auth/refresh-token. This is where I got confused, the Action Server was unable to read the contents of this RefreshToken cookie as it was sent via the /app subdomain, therefore it did not reach the Action Server at that time and was not able to relay it to the REST API at https:// /api_domain/auth/refresh-token.

    As the only sensitive data in this scenario are the Login/Password credentials for the first access to obtain the tokens, I placed only this in an Action Server to be handled on the server side, and kept the interceptors as client components, therefore REST API records cookies directly on the client and the RefreshToken in '/auth' will be sent correctly to https:// /api_domain/auth/refresh-token.

    //apiLogin.js
    'use server';
    
    import axios from 'axios';
    import setCookieParser from 'set-cookie-parser';
    import { cookies } from 'next/headers';
    
    const proxyServerCookies = (cookieNames, response) => {
        if (response.headers.has('set-cookie')) {
            const cookieString = response.headers.get('set-cookie');
            const cookieObject = setCookieParser.parse(setCookieParser.splitCookiesString(cookieString), {
                map: true,
            });
    
            cookieNames.forEach(async (cookieName) => {
                if (cookieObject[cookieName]) {
                    const cookie = cookieObject[cookieName];
    
                    await cookies().set(cookieName, cookie.value, {
                        path: cookie.path,
                        domain: cookie.domain,
                        maxAge: cookie.maxAge,
                        sameSite: (cookie.sameSite),
                        expires: cookie.expires,
                        secure: cookie.secure,
                        httpOnly: cookie.httpOnly,
                    });
                }
            });
        };
    };
    
    export const apiLogin = async () => {
        const response = await axios.post(process.env.NEXT_PUBLIC_API_APP + '/auth/login',
            {
                name: process.env.API_USERNAME,
                password: process.env.API_PASSWORD
            });
        proxyServerCookies(['accessToken', 'refreshToken'], response);
    }
    
    //axiosPrivate.js
    'use client';
    
    import axios from 'axios';
    import { apiLogin } from './apiLogin';
    
    const axiosPrivate = axios.create({
        baseURL: process.env.NEXT_PUBLIC_API_APP,
        withCredentials: true
    });
    
    axiosPrivate.interceptors.request.use(
        async (config) => {
            config.timeout = 5000;
            return config;
        }, (error) => Promise.reject(error)
    );
    
    axiosPrivate.interceptors.response.use(
        response => response,
        async (error) => {
            const prevRequest = error?.config;
            if (error.response?.status === 401 &&
                (error.response.data?.code === 'AccessTokenExpired' || error.response.data?.code === 'AccessTokenNotFound')
            ) {
                try {
                    await axios.post(process.env.NEXT_PUBLIC_API_APP + '/auth/refresh-token', {},
                        { withCredentials: true }
                    );
                    return axiosPrivate(prevRequest);
                } catch (error) {
                    try {
                        //Here is the call to Action Server with sensitive data that will be processed on the server side.
                        await apiLogin();
                        return axiosPrivate(prevRequest);
                    } catch (error) {
                        return Promise.reject(error);
                    };
                };
            };
            return Promise.reject(error);
        }
    );
    
    export default axiosPrivate;