Monday, 26 October 2020

Nextjs - Auth token stored in memory + refresh token in HTTP Only cookie

I am currently implementing an authentication flow using Nextjs and an api using Expressjs.

I am looking to store a JWT token as an auth token in memory that I can periodically refresh using a refresh token stored in an HTTPOnly cookie.

For my implementation, I took as a reference the nice OSS project here.

My issue here is that when I am storing the auth token in inMemoryToken during login, the value is stored only available client side but still available server side and vice versa.

Another example is when I am disconnecting:

  • inMemoryToken is equal to something server side
  • user click the logout button and logout() is called front side and inMemoryToken = null
  • on page change, getServerSideProps() is called on the server but inMemoryToken on the server is still equal to the previous value and therefore my user still appear connected.

Here is the Nextjs code

//auth.js
import { Component } from 'react';
import Router from 'next/router';
import { serialize } from 'cookie';
import { logout as fetchLogout, refreshToken } from '../services/api';

let inMemoryToken;

export const login = ({ accessToken, accessTokenExpiry }, redirect) => {
    inMemoryToken = {
        token: accessToken,
        expiry: accessTokenExpiry,
    };
    if (redirect) {
        Router.push('/');
    }
};

export const logout = async () => {
    inMemoryToken = null;
    await fetchLogout();
    window.localStorage.setItem('logout', Date.now());
    Router.push('/');
};

const subMinutes = (dt, minutes) => {
    return new Date(dt.getTime() - minutes * 60000);
};

export const withAuth = (WrappedComponent) => {
    return class extends Component {
        static displayName = `withAuth(${Component.name})`;

        state = {
            accessToken: this.props.accessToken,
        };
        async componentDidMount() {
            this.interval = setInterval(async () => {
                inMemoryToken = null;
                const token = await auth();
                inMemoryToken = token;
                this.setState({ accessToken: token });
            }, 60000);
            window.addEventListener('storage', this.syncLogout);
        }

        componentWillUnmount() {
            clearInterval(this.interval);
            window.removeEventListener('storage', this.syncLogout);
            window.localStorage.removeItem('logout');
        }

        syncLogout(event) {
            if (event.key === 'logout') {
                Router.push('/');
            }
        }

        render() {
            return (
                <WrappedComponent
                    {...this.props}
                    accessToken={this.state.accessToken}
                />
            );
        }
    };
};

export const auth = async (ctx) => {
    console.log('auth ', inMemoryToken);
    if (!inMemoryToken) {
        inMemoryToken = null;
        const headers =
            ctx && ctx.req
                ? {
                      Cookie: ctx.req.headers.cookie ?? null,
                  }
                : {};
        await refreshToken(headers)
            .then((res) => {
                if (res.status === 200) {
                    const {
                        access_token,
                        access_token_expiry,
                        refresh_token,
                        refresh_token_expiry,
                    } = res.data;
                    if (ctx && ctx.req) {
                        ctx.res.setHeader(
                            'Set-Cookie',
                            serialize('refresh_token', refresh_token, {
                                path: '/',
                                expires: new Date(refresh_token_expiry),
                                httpOnly: true,
                                secure: false,
                            }),
                        );
                    }
                    login({
                        accessToken: access_token,
                        accessTokenExpiry: access_token_expiry,
                    });
                } else {
                    let error = new Error(res.statusText);
                    error.response = res;
                    throw error;
                }
            })
            .catch((e) => {
                console.log(e);
                if (ctx && ctx.req) {
                    ctx.res.writeHead(302, { Location: '/auth' });
                    ctx.res.end();
                } else {
                    Router.push('/auth');
                }
            });
    }
    const accessToken = inMemoryToken;
    if (!accessToken) {
        if (!ctx) {
            Router.push('/auth');
        }
    }
    return accessToken;
};

//page index.js

import Head from 'next/head';
import { Layout } from '../components/Layout';
import { Navigation } from '../components/Navigation';
import { withAuth, auth } from '../libs/auth';

const Home = ({ accessToken }) => (
    <Layout>
        <Head>
            <title>Home</title>
        </Head>
        <Navigation />
        <div>
            <p>
                Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
                eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
                enim ad minim veniam, quis nostrud exercitation ullamco laboris
                nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor
                in reprehenderit in voluptate velit esse cillum dolore eu fugiat
                nulla pariatur. Excepteur sint occaecat cupidatat non proident,
                sunt in culpa qui officia deserunt mollit anim id est laborum.
            </p>
        </div>
    </Layout>
);

export const getServerSideProps = async (ctx) => {
    const accessToken = await auth(ctx);
    return {
        props: { accessToken: accessToken ?? null },
    };
};

export default withAuth(Home);

Part of the express js code:

app.post('/api/login', (req, res) => {
    const { username, password } = req.body;

    ....

    const refreshToken = uuidv4();
    const refreshTokenExpiry = new Date(new Date().getTime() + 10 * 60 * 1000);

    res.cookie('refresh_token', refreshToken, {
        maxAge: 10 * 60 * 1000,
        httpOnly: true,
        secure: false,
    });

    res.json({
        access_token: accessToken,
        access_token_expiry: accessTokenExpiry,
        refresh_token: refreshToken,
        user,
    });
});

app.post('/api/refresh-token', (req, res) => {
    const refreshToken = req.cookies['refresh_token'];
    
    .....

    const newRefreshToken = uuidv4();
    const newRefreshTokenExpiry = new Date(
        new Date().getTime() + 10 * 60 * 1000,
    );

    res.cookie('refresh_token', newRefreshToken, {
        maxAge: 10 * 60 * 1000,
        httpOnly: true,
        secure: false,
    });

    res.json({
        access_token: accessToken,
        access_token_expiry: accessTokenExpiry,
        refresh_token: newRefreshToken,
        refresh_token_expiry: newRefreshTokenExpiry,
    });
});

app.post('/api/logout', (_, res) => {
    res.clearCookie('refresh_token');
    res.sendStatus(200);
});

What I understand is that even if the let inMemoryToken is declared once, two separate instance of it will be available at runtime, one client side and one server side, and modifying on doesn't affect the other. Am I right?

In this case, how to solved this since the auth method can be called on the server but also on the client?



from Nextjs - Auth token stored in memory + refresh token in HTTP Only cookie

No comments:

Post a Comment