diff --git a/backend/typescript/services/implementations/authService.ts b/backend/typescript/services/implementations/authService.ts index 83a81ab3..212afdd5 100644 --- a/backend/typescript/services/implementations/authService.ts +++ b/backend/typescript/services/implementations/authService.ts @@ -102,6 +102,7 @@ class AuthService implements IAuthService { async generateSignInLink(email: string): Promise { const actionCodeSettings = { + // Why this localhost lmao url: `http://localhost:3000/login/?email=${email}`, handleCodeInApp: true, }; diff --git a/frontend/src/APIClients/AuthAPIClient.ts b/frontend/src/APIClients/AuthAPIClient.ts index 58c53a59..8e00084a 100644 --- a/frontend/src/APIClients/AuthAPIClient.ts +++ b/frontend/src/APIClients/AuthAPIClient.ts @@ -4,10 +4,8 @@ import auth from "../firebase"; import AUTHENTICATED_USER_KEY from "../constants/AuthConstants"; import { AuthenticatedUser, PasswordSetResponse } from "../types/AuthTypes"; import baseAPIClient from "./BaseAPIClient"; -import { - getLocalStorageObjProperty, - setLocalStorageObjProperty, -} from "../utils/LocalStorageUtils"; +import { getLocalStorageObjProperty } from "../utils/LocalStorageUtils"; +import { refreshAccessToken } from "../utils/AuthUtils"; const login = async ( email: string, @@ -107,23 +105,8 @@ const sendPasswordResetEmail = async ( } }; -// for testing only, refresh does not need to be exposed in the client const refresh = async (): Promise => { - try { - const { data } = await baseAPIClient.post( - "/auth/refresh", - {}, - { withCredentials: true }, - ); - setLocalStorageObjProperty( - AUTHENTICATED_USER_KEY, - "accessToken", - data.accessToken, - ); - return true; - } catch (error) { - return false; - } + return refreshAccessToken(); }; const getEmailOfCurrentUser = async (): Promise => { diff --git a/frontend/src/APIClients/BaseAPIClient.ts b/frontend/src/APIClients/BaseAPIClient.ts index 39d88c33..bfef8036 100644 --- a/frontend/src/APIClients/BaseAPIClient.ts +++ b/frontend/src/APIClients/BaseAPIClient.ts @@ -1,12 +1,21 @@ -import axios, { AxiosRequestConfig } from "axios"; +import axios, { + AxiosRequestConfig, + type AxiosResponse, + type AxiosError, +} from "axios"; import { jwtDecode } from "jwt-decode"; - -import AUTHENTICATED_USER_KEY from "../constants/AuthConstants"; import { DecodedJWT } from "../types/AuthTypes"; -import { setLocalStorageObjProperty } from "../utils/LocalStorageUtils"; +import { + refreshAccessToken, + validateAccessToken, + getAccessToken, + clearAccessToken, +} from "../utils/AuthUtils"; + +const API_BASE = process.env.REACT_APP_BACKEND_URL || ""; const baseAPIClient = axios.create({ - baseURL: process.env.REACT_APP_BACKEND_URL, + baseURL: API_BASE, }); baseAPIClient.interceptors.request.use(async (config: AxiosRequestConfig) => { @@ -17,10 +26,11 @@ baseAPIClient.interceptors.request.use(async (config: AxiosRequestConfig) => { newConfig.headers = {}; } - // if access token in header has expired, do a refresh + // Get access token const authHeader = config.headers?.Authorization; - const authHeaderParts = - typeof authHeader === "string" ? authHeader.split(" ") : null; + const headerString = typeof authHeader === "string" ? authHeader : undefined; + const authHeaderParts = headerString?.trim().split(/\s+/) ?? []; + // Check that authHeaderParts is in the [scheme, token] format if ( config.headers && // Add this check authHeaderParts && @@ -29,32 +39,35 @@ baseAPIClient.interceptors.request.use(async (config: AxiosRequestConfig) => { ) { const decodedToken = jwtDecode(authHeaderParts[1]) as DecodedJWT; - if ( - decodedToken && - (typeof decodedToken === "string" || - decodedToken.exp <= Math.round(new Date().getTime() / 1000)) - ) { - const { data } = await axios.post( - `${process.env.REACT_APP_BACKEND_URL}/auth/refresh`, - {}, - { withCredentials: true }, - ); - - const accessToken = data.accessToken || data.access_token; - setLocalStorageObjProperty( - AUTHENTICATED_USER_KEY, - "accessToken", - accessToken, - ); - - if (!newConfig.headers) { - newConfig.headers = {}; + // Check if the access_token is expired, if it is then request a refresh + if (!validateAccessToken(decodedToken)) { + const refreshCode = await refreshAccessToken(); + + if (refreshCode) { + const accessToken = getAccessToken(); + newConfig.headers.Authorization = `Bearer ${accessToken}`; } - newConfig.headers.Authorization = accessToken; } } return newConfig; }); +// If the user tries to access restricted endpoints then redirect them +// We should be careful with the error codes since now 401 and 403 will cause redirects +// TODO: Handle permissions +baseAPIClient.interceptors.response.use( + (response: AxiosResponse) => response, + (error: AxiosError) => { + if (error.response?.status === 401 || error.response?.status === 403) { + // clear user data + clearAccessToken(); + + // redirect to login page + window.location.href = "/login"; + } + return Promise.reject(error); + }, +); + export default baseAPIClient; diff --git a/frontend/src/utils/AuthUtils.ts b/frontend/src/utils/AuthUtils.ts new file mode 100644 index 00000000..35d640ad --- /dev/null +++ b/frontend/src/utils/AuthUtils.ts @@ -0,0 +1,64 @@ +import axios from "axios"; +import { DecodedJWT } from "../types/AuthTypes"; +import { + getLocalStorageObjProperty, + setLocalStorageObjProperty, + clearLocalStorageKey, +} from "./LocalStorageUtils"; +import AUTHENTICATED_USER_KEY from "../constants/AuthConstants"; + +const API_BASE = process.env.REACT_APP_BACKEND_URL || ""; + +// Refreshes the access token +// The reason its here and not in AuthAPIClient is because it creates a cyclic dependanyc with BaseAPIClient +export const refreshAccessToken = async (): Promise => { + try { + const { data } = await axios.post( + `${API_BASE}/auth/refresh`, + {}, + { withCredentials: true }, + ); + + // update stored access token + setLocalStorageObjProperty( + AUTHENTICATED_USER_KEY, + "accessToken", + data.accessToken, + ); + + return true; + } catch (error) { + // Clear the access token cookie + clearLocalStorageKey(AUTHENTICATED_USER_KEY); + return false; + } +}; + +// Gets your access token from the cookies +export const getAccessToken = (): string | null => { + try { + const accessToken = String( + getLocalStorageObjProperty(AUTHENTICATED_USER_KEY, "accessToken"), + ); + return accessToken; + } catch (error) { + return null; + } +}; + +// Checks if the access token has expired or not +export const validateAccessToken = (decodedToken: DecodedJWT): boolean => { + // Check if expired + const result = + decodedToken && + // If it a string (and not an object) then something went wrong + (typeof decodedToken === "string" || + // Checks the time of expiration in seconds (division by 1000 because its in ms) + decodedToken.exp <= Math.round(new Date().getTime() / 1000)); + return !result; +}; + +// Removes the access token +export const clearAccessToken = (): void => { + clearLocalStorageKey(AUTHENTICATED_USER_KEY); +}; diff --git a/frontend/src/utils/LocalStorageUtils.ts b/frontend/src/utils/LocalStorageUtils.ts index 60a13bf0..f4ac600b 100644 --- a/frontend/src/utils/LocalStorageUtils.ts +++ b/frontend/src/utils/LocalStorageUtils.ts @@ -39,3 +39,9 @@ export const setLocalStorageObjProperty = >( object[property] = value; localStorage.setItem(localStorageKey, JSON.stringify(object)); }; + +// Clear a specific key from localStorage +// TODO: add a try-catch with a logger +export const clearLocalStorageKey = (localStorageKey: string): void => { + localStorage.removeItem(localStorageKey); +};