0

I'm trying to do authorization using JWT access&refresh tokens (Next.js SSR + Redux Toolkit RTK Query + NestJS). When I receive a response from the server on the client (for example using Postman) the cookies sent by the server are saved. But when I do it on SSR using RTK Query Set-Cookie from the server just doesn't do anything. Sorry if I misunderstood something, I'm new to this.

NSETJS auth.controller.ts:

import {
  Controller,
  Get,
  HttpCode,
  HttpStatus,
  Post,
  Req,
  Res,
  UseGuards
} from '@nestjs/common';
import { Response } from 'express';
import { ApiTags } from '@nestjs/swagger';
import { Request } from 'express';

import { AuthService } from './auth.service';
import { DiscordAuthGuard } from './guards/discord-auth.guard';
import JwtAuthGuard from './guards/jwt-auth.guard';
import { RequestWithUser } from './auth.interface';
import JwtRefreshGuard from './guards/jwt-auth-refresh.guard';

import { UserService } from '@/modules/user/user.service';

@Controller('auth')
@ApiTags('Auth routes')
export class AuthController {
  constructor(
    private readonly authService: AuthService,
    private readonly userService: UserService
  ) {}

  @Get('login')
  @HttpCode(HttpStatus.OK)
  @UseGuards(DiscordAuthGuard)
  login(@Req() _req: Request) {}

  @Get('redirect')
  @HttpCode(HttpStatus.OK)
  @UseGuards(DiscordAuthGuard)
  async redirect(@Req() req: RequestWithUser, @Res() res: Response) {
    const { user } = req;
    const accessTokenCookie = this.authService.getCookieWithJwtAccessToken(
      user.id
    );
    const refreshTokenCookie = this.authService.getCookieWithJwtRefreshToken(
      user.id
    );

    await this.userService.setCurrentRefreshToken(
      refreshTokenCookie.token,
      user.id
    );

    req.res.setHeader('Set-Cookie', [
      accessTokenCookie.cookie,
      refreshTokenCookie.cookie
    ]);
    return res.redirect('http://localhost:3000');
  }

  @Get('refresh')
  @HttpCode(HttpStatus.OK)
  @UseGuards(JwtRefreshGuard)
  async refresh(@Req() req: RequestWithUser) {
    const { user } = req;
    const accessTokenCookie = this.authService.getCookieWithJwtAccessToken(
      user.id
    );

    req.res.setHeader('Set-Cookie', [accessTokenCookie.cookie]);

    return user;
  }

  @Get('me')
  @HttpCode(HttpStatus.OK)
  @UseGuards(JwtAuthGuard)
  me(@Req() req: RequestWithUser) {
    const { user } = req;
    return user;
  }

  @Post('logout')
  @HttpCode(HttpStatus.OK)
  @UseGuards(JwtAuthGuard)
  async logout(@Req() req: RequestWithUser) {
    const { user } = req;
    await this.userService.removeRefreshToken(user.id);
    req.res.setHeader('Set-Cookie', this.authService.getCookiesForLogOut());
  }
}

NEXT.JS _app.tsx:

import '@/styles/globals.scss';

import { AppProps } from 'next/app';

import { wrapper } from '@/store';
import { me } from '@/store/auth/auth.api';
import { setCredentials } from '@/store/auth/auth.slice';

function App({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />;
}

App.getInitialProps = wrapper.getInitialAppProps(
  (store) =>
    async ({ ctx, Component }) => {
      try {
        const { data: user } = await store.dispatch(me.initiate());
        if (user !== undefined) {
          store.dispatch(
            setCredentials({
              user,
            })
          );
        }
      } catch (err) {
        console.log(err);
      }
      return {
        pageProps: {
          ...(Component.getInitialProps
            ? await Component.getInitialProps({ ...ctx, store })
            : {}),
        },
      };
    }
);

export default wrapper.withRedux(App);

NEXT.JS auth.api.ts:

import { parseCookies } from 'nookies';
import {
  BaseQueryFn,
  createApi,
  fetchBaseQuery,
} from '@reduxjs/toolkit/query/react';
import { HYDRATE } from 'next-redux-wrapper';
import { Mutex } from 'async-mutex';
import { NextPageContext } from 'next/types';

import { IUser } from './auth.interface';
import { destroyCredentials } from './auth.slice';

const mutex = new Mutex();

const baseQuery = fetchBaseQuery({
  baseUrl: 'http://localhost:7777/api/auth',
  prepareHeaders: (headers, { extra }) => {
    const ctx = extra as Pick<NextPageContext<any>, 'req'>;
    const windowAvailable = () =>
      !!(
        typeof window !== 'undefined' &&
        window.document &&
        window.document.createElement
      );

    if (windowAvailable()) {
      console.log('running on browser, skipping header manipulation');
      return headers;
    }

    const cookies = parseCookies(ctx);

    // Build a cookie string from object
    const cookieValue = Object.entries(cookies)
      // .filter(([k]) => k === 'JSESSIONID') // only include relevant cookies
      .map(([k, v]) => `${k}=${v}`) // rfc6265
      .join('; ');
    console.log('figured out cookie value: ' + cookieValue);

    headers.set('Cookie', cookieValue);

    return headers;
  },
  credentials: 'include',
});

const baseQueryWithReauth: BaseQueryFn = async (args, api, extraOptions) => {
  await mutex.waitForUnlock();
  console.log(' sending request to server');
  let result = await baseQuery(args, api, extraOptions);

  if (result?.error?.status === 401) {
    if (!mutex.isLocked()) {
      console.log(' 401, sending refresh token');
      const release = await mutex.acquire();

      try {
        const refreshResult = await baseQuery('refresh', api, extraOptions);

        const setCookies =
          refreshResult.meta?.response?.headers.get('set-cookie');
        console.log(' response set-cookies:', setCookies);

        console.log(
          ' refresh response status:',
          refreshResult.meta?.response?.status
        );

        if (refreshResult.data) {
          const windowAvailable = () =>
            !!(
              typeof window !== 'undefined' &&
              window.document &&
              window.document.createElement
            );

          if (!windowAvailable()) {
            console.log(' running on server');
          }

          result = await baseQuery(args, api, extraOptions);
          console.log(
            ' request response status after /refresh',
            result.meta?.response?.status
          );
        } else {
          api.dispatch(destroyCredentials());
        }
      } finally {
        release();
      }
    }
  }

  return result;
};

export const authApi = createApi({
  reducerPath: 'api/auth',
  baseQuery: baseQueryWithReauth,

  extractRehydrationInfo(action, { reducerPath }) {
    if (action.type === HYDRATE) {
      return action.payload[reducerPath];
    }
  },

  tagTypes: ['Auth'],
  endpoints: (build) => ({
    me: build.query<IUser, void>({
      query: () => ({
        url: '/me',
        method: 'GET',
      }),
      providesTags: ['Auth'],
    }),
    logout: build.mutation<void, void>({
      query: () => ({
        url: '/logout',
        method: 'POST',
      }),
      invalidatesTags: ['Auth'],
    }),
  }),
});

// Export hooks for usage in functional components
export const {
  useMeQuery,
  useLogoutMutation,
  util: { getRunningOperationPromises },
} = authApi;

// export endpoints for use in SSR
export const { me, logout } = authApi.endpoints;

NEXT.JS auth.slice.ts

import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { HYDRATE } from 'next-redux-wrapper';

import { RootState } from '..';

import { IUser } from './auth.interface';

export interface IAuthState {
  user: IUser | null;
}

const initialState: IAuthState = {
  user: null,
};

export const authSlice = createSlice({
  name: 'auth',
  initialState,
  reducers: {
    setCredentials: (
      state,
      action: PayloadAction<{
        user: IUser | null;
      }>
    ) => {
      const { user } = action.payload;
      state.user = user;
    },
    destroyCredentials: (state) => {
      state.user = null;
    },
  },
  extraReducers: {
    [HYDRATE]: (state, action) => {
      return {
        ...state,
        ...action.payload.auth,
      };
    },
  },
});

export const { setCredentials, destroyCredentials } = authSlice.actions;

export const selectCurrentUser = (state: RootState) => state.auth.user;

export default authSlice.reducer;

NEXT.JS store/index.ts:

import {
  configureStore,
  ImmutableStateInvariantMiddlewareOptions,
  SerializableStateInvariantMiddlewareOptions,
  ThunkAction,
} from '@reduxjs/toolkit';
import { Action, combineReducers } from 'redux';
import { Context, createWrapper } from 'next-redux-wrapper';

import botApi from './bots/bot.api';
import authReducer from './auth/auth.slice';
import { authApi } from './auth/auth.api';

// ThunkOptions not exported in getDefaultMiddleware, so we have a copy here
interface MyThunkOptions<E> {
  extraArgument: E;
}

// GetDefaultMiddlewareOptions in getDefaultMiddleware does not allow
// providing type for ThunkOptions, so here is our custom version
// https://redux-toolkit.js.org/api/getDefaultMiddleware#api-reference
interface MyDefaultMiddlewareOptions {
  thunk?: boolean | MyThunkOptions<Context>;
  immutableCheck?: boolean | ImmutableStateInvariantMiddlewareOptions;
  serializableCheck?: boolean | SerializableStateInvariantMiddlewareOptions;
}

const rootReducer = combineReducers({
  // Add the generated reducer as a specific top-level slice
  [botApi.reducerPath]: botApi.reducer,
  [authApi.reducerPath]: authApi.reducer,
  auth: authReducer,
});

const makeStore = (wtf: any) => {
  const ctx = wtf.ctx as Context;
  return configureStore({
    reducer: rootReducer,
    // Adding the api middleware enables caching, invalidation, polling,
    // and other useful features of `rtk-query`.
    middleware: (gDM) =>
      gDM<MyDefaultMiddlewareOptions>({
        thunk: {
          // https://github.com/reduxjs/redux-toolkit/issues/2228#issuecomment-1095409011
          extraArgument: ctx,
        },
      }).concat(botApi.middleware, authApi.middleware),
    devTools: process.env.NODE_ENV !== 'production',
  });
};

export type RootState = ReturnType<AppStore['getState']>;
export type AppStore = ReturnType<typeof makeStore>;
export type AppDispatch = ReturnType<typeof makeStore>;
export type AppThunk<ReturnType = void> = ThunkAction<
  ReturnType,
  RootState,
  unknown,
  Action
>;

export const wrapper = createWrapper<AppStore>(makeStore);

I would be grateful to everyone for any help with a solution .

monotype
  • 217
  • 2
  • 10
  • If I do this without SSR (for example in useEffect), then Set-Cookie from the server successfully saves cookies. Is it possible to somehow get cookies from the server using SSR? – monotype Jul 19 '22 at 14:09
  • _"But when I do it on SSR using RTK Query Set-Cookie from the server just doesn't do anything"_ - Can you show us the code where are you doing SSR using RTK Query? – juliomalves Jul 19 '22 at 22:36
  • @juliomalves, Yes, I also noticed that I wrote a little garbage. i use getInitialAppProps even though i need to send request from GetServerSideProps. I've already tweaked this and figured out how to pass cookies to the client and update the header rtk query with new cookies from the server, parsing them from Set-Cookie and passing them to GetServerSidePropsContext res and req which I pass as extra. – monotype Jul 20 '22 at 09:48
  • @juliomalves I wanted to ask, I moved the dispatch query call in intex.js (on the main page) to the getServerSideProps function wrapped in next-redux-wrapper. But now it turns out that I send a request to get the user only on the home page, but I need it to be sent on all pages. Since I can't do it in _app, where is the right place to send a request to /me to get the user and for this call to be on SSR, maybe some kind of wrapper is needed? – monotype Jul 20 '22 at 09:49
  • _"I can't do it in _app"_ - Why can't you do it in `App.getInitialProps`? Alternatively, you could call it on each page `getServerSideProps`. – juliomalves Jul 20 '22 at 09:51
  • 1
    @juliomalves Ah, yes . I will do this in _app getInitialAppProps, but I still need checks when I get req or res because the location of the NextPageContext changes if you call query in getInitialAppProps then this is ctx.ctx.req or getServerSideProps ctx.req and if you call this query in CSR then this also needs to be taken into account that there is no NextPageContext. Thanks a lot for your answer. – monotype Jul 20 '22 at 11:34

0 Answers0