Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/typescript/services/implementations/authService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ class AuthService implements IAuthService {

async generateSignInLink(email: string): Promise<string> {
const actionCodeSettings = {
// Why this localhost lmao
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah we will replace this in production. It is localhost for now because firebase requires a valid url for email link sign in

url: `http://localhost:3000/login/?email=${email}`,
handleCodeInApp: true,
};
Expand Down
23 changes: 3 additions & 20 deletions frontend/src/APIClients/AuthAPIClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -107,23 +105,8 @@ const sendPasswordResetEmail = async (
}
};

// for testing only, refresh does not need to be exposed in the client
const refresh = async (): Promise<boolean> => {
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<string> => {
Expand Down
71 changes: 42 additions & 29 deletions frontend/src/APIClients/BaseAPIClient.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -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 &&
Expand All @@ -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;
64 changes: 64 additions & 0 deletions frontend/src/utils/AuthUtils.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> => {
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 => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the logic backwards?

// 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);
};
6 changes: 6 additions & 0 deletions frontend/src/utils/LocalStorageUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,9 @@ export const setLocalStorageObjProperty = <O extends Record<string, string>>(
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);
};
Loading