Lesson 13: Authentication - strvcom/frontend-academy-2022 Wiki

Speaker: Aron Berezkin

Resources

Lesson notes

Authentication Overview

Knowing good authentication patterns is a super important part of Frontend development, because we will face authentication implementation almost in every app. While it feels scary, mostly due to its security connection, when we outline and follow a reasonable implementation framework, the complex connected map of interactions becomes just series of steps to fulfil.

Commonly we use authentication to identify the user and based on that show them, or prevent them from seeing, particular content in the application. This can be extended to actions they are (or not) allowed to take. Good authentication options improve UX by making the user feel safe and allowing them to smoothly use the website without unnecessary interruptions, such as inputting credentials on every reload unless they specifically choose to do so. A good example is a remember me option when user gives explicitly permission to persist their details in the browser, or restoring browsing website history after being signed out in the middle of a particular flow and consequently logged in back.

Common Authentication Methods:

  • credentials
  • social login
  • magic link
  • one time password (otp)
  • biometrics
  • authenticator apps
  • 2FA combination of above

For a Frontend Engineer all these methods usually yield authentication tokens that we use when communicating with the backend API to authorize responses. Commonly we use JWT tokens which are issued, signed, and/or verified by the backend.

JWT Tokens

Access Token - is issued after successful authentication and used by Frontend until it expires. In case of expiry, a request to backend should fail with a predictable status 403 (401 is received when access token is not provided at all). How long is the expiration date depends on the security profile of the app - for financial application you should expect length in minutes or hours, while for non-security heavy apps it can be even a month or longer. Every token should have expiration date though. We usually append access token to request's Authorization header.

Refresh Token - is also issued after successful authentication and used until its expiry, which is usually significantly longer than for the access token. It has a single purpose to be sent to the API endpoint which will issue a new access token upon refresh token verification, allowing the users to continue their session without interruption.

Storing JWT Tokens

LocalStorage - is accessed on the global window object and basically reflects synchronous browser storage which allows retrieving items upon page reload, for example to retrieve the access token and provide it to our API client. It is susceptible to Cross-Site Scripting(XSS) vulnerability when malicious javascript in our app/website can retrieve the tokens from localstorage and send them to an external endpoint/database. Losing refresh token this way would be very troublesome. Server cannot interact with localstorage, therefore it allows client authentication flows only.

Cookies - can be accessed by javascript from the client but usually they are used in server side environment only, when backend sets a particular cookie on the API response and this cookie is then automatically attached to every API request between Frontend and Backend without us Frontend engineers doing anything. This is quite handy place for refresh token and not that much for access token because cookies are susceptible to Cross-Site Request Forgery (CSRF) vulnerability when a hacked makes the user initiate requests they don't want to. With cookies we can implement both client and server authentication flows.

App Memory - and by that I mean just pure const or useState, which is probably the safest way how to don't allow anyone to hijack our access and refresh token, but obviously this way the tokens cannot be persisted. Therefore it is a good fit only for access token.

The Best PRACTICE Effort

Every approach will have some security risks and therefore we always can only make the best effort to mitigate certain vulnerabilities. One of the good approaches to tokens storage is to keep refresh token in httpOnly, secure, sameSite cookie and access token in app memory only. This way on every page reload we would lose the access token but thanks to having access to the cookies on the server side, we could generate a new one and provide it back to our client application - this flow fits Next.js SSR capabilities very well. A very good overview of such approach can be found in this hasura article.

Authentication Implementation Checklist

Following this checklist should allow you to implement a typical functioning authentication flow with an access token and a refresh token. It is your choice where you decide to persist tokens and user information but lets have a look at the options:

  1. Login Request
    • Redirect
    • Reflect Logged In State in UI - Store User
      • Handle Page Reload - Persist User
    • Save or Persist JWT Tokens
    • Optional: Persist Only When Remember me Checked
  2. Logout - Allow Other User
    • Clear Global State and Persisted Stuff
  3. Private API client
    • Append Access Token to Authorization Header
  4. Handle Access Token Expiration (403 status)
    • Use Refresh Token endpoint
    • Append Access Token to Authorization Header
    • Repeat Initially Failed Request
  5. Handle Refresh Token Expiration
    • Logout - Clear Global State and Persisted Stuff
    • Redirect to Login
  6. Handle Protected Routes
    • Redirect to Login if User is Not Authorized (role, logged in)
    • NiceToHave: After Login Redirect to Previously Requested Page

Persisting User Information

This is usually important to reflect in the UI user's presence, typically in website's profile section or header. Usually backend offers as an endpoint to get current user information. This is a perfect candidate for React Query useQuery hook as thanks to the cache we can use the query across the whole application instead of context or other global state manager. We don't need userId either as a good backend should extract it from the JWT access token we append to the request. If we don't have the endpoint as in Eventio case, we just store the user info globally (context, redux, zustand) and persist it (localstorage).

Persisting Tokens

As discussed in Authentication Overview, having refresh token in the cookie should be preferred.

Logout

Whatever we persist relating to the logged-in user has to be cleaned upon logout so that we can prepare clean application state for a new user. Note that logout should happen also when the refresh token expires, or during access token expiration if refresh token does not exist.

API Client

Creating a custom API client as in the networking academy lesson is not preferred (though knowing how to is great), given existing tested and comprehensive solutions such as ky, which are extending the client's capabilities with before and after interceptors (hooks in ky terminology) or even retry logic.

Interceptors are handy in many ways:

after request

  • persisting tokens
  • transforming irregular backend response into consistent data object
  • refreshing access token in case of 403 status
  • repeating failed request with a fresh access token
  • logout action in case of expired refresh token

before request

  • appending access token to the authorization header

Refresh token flow

Probably the most important interceptor is the one to handle expired access token when we receive 403 from the backend (not that great backend might return 400 status with a specific error message). Upon 403, we know we should use stored refresh token and hit the refresh API endpoint to get a fresh access token. If the refresh succeeds, we repeat the failed request with the same parameters. And if the refresh fails, we logout the user and redirect them to a login screen.

const handleUnauthorized: AfterRequestInterceptor = async (
  request,
  options,
  response,
  context
) => {
  if (response.status === 403 || response.status === 401) {
    const refreshToken = getRefreshToken()
    if (!refreshToken) {
      return response
    }

    // persistTokens interceptor will store the tokens if refresh succeeds
    const refreshResponse = await api.post('/auth/native', {
      json: { refreshToken },
    })
    if (refreshResponse.status >= 400) {
      void router.replace({
        pathname: Routes.LOGIN,
        // we need to clear persisted stuff and context
        query: { from: 'unauthorized' },
      })
      return response
    }

    // repeat request with fresh accessToken
    return await context.client.makeRequest(request.url, { ...options })
  }

  return response
}

Protecting Routes

There are routes that should be access by logged-in users only, or by users with specified roles. If the user is not logged in and tries to access a protected route, we should redirect them to login. In Next.js doing this on the client can be achieved by a reusable higher order component implementation:

import type { NextPage } from 'next'
import { useRouter } from 'next/router'
import { useEffect } from 'react'

import { useUserContext } from '~/features/auth/contexts/userContext'
import { Routes } from '~/features/core/constants/routes'

export const withPrivateRoute = (WrappedComponent: NextPage): NextPage => {
  const HOCComponent: NextPage = ({ ...props }) => {
    const router = useRouter()
    const { user } = useUserContext()
    useEffect(() => {
      if (!user) void router.replace(Routes.LOGIN)
    }, [router, user])

    return <WrappedComponent {...props} />
  }

  return HOCComponent
}

...and used in the page

export const CreateEventPage = withPrivateRoute(Page)

But with a client implementation and in the case the protected route is users first entry to the website, the first render of the page will likely happen without us knowing if the user is logged in or not (eg. in eventio case, we have to wait for our useEffect to get persisted user from the localstorage), and that is why redirect will come bit 'late'.

We either let the user see the protected route for a split second, or show them a blank page (if (!user) return null) or a loading spinner(if(!user) return <Spinner/>) until we get the user's state. That is why having access to the user's access or refresh token during server side rendering can help us improving UX by figuring out user's logged-in state before protected route is rendered. See advanced (though simple) middleware implementation:

⚠️ **GitHub.com Fallback** ⚠️