I need to append the current user to my authHeader so the backend can authorise access to the route called. The code below exposes an rxjs BehaviorSubject to provide the user to any component that calls it.
user.service.js
import { BehaviorSubject } from "rxjs";
const userSubject = new BehaviorSubject(null);
export const userService = {
user: userSubject.asObservable(), // Observable for the current user
get userValue() {
return userSubject.value; // Get the current user value
},
};
However, when I call the userSubject from another component with the code below, the value comes back as NULL.
fetch-wrapper.js
import { userService } from "../features/users/user.service";
export const fetchWrapper = {
post,
};
function post(url, body) {
console.log("fetchWrapper user: ", userService.userValue); // Early check is NULL
const requestOptions = {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeader(url) },
credentials: "include",
body: JSON.stringify(body),
};
return fetch(url, requestOptions).then(handleResponse);
}
function authHeader(url) {
// return auth header with jwt if user is logged in and request is to the api url
const user = userService.userValue;
console.log("authHeader user", user); // Value is still NULL
const isLoggedIn = user && user.jwtToken;
const isApiUrl = url.startsWith(config.apiUrl);
if (isLoggedIn && isApiUrl) {
return { Authorization: `Bearer ${user.jwtToken}` };
} else {
return {};
}
}
Does anyone have advice on how to use the userSubject properly so I can access the userSubject.value please? I've reviewed other articles on the web and haven't been able to find anything. I suspect it's a timing issue but I'm not sure how to wait for the value to be updated. But if it is a timing problem, then I don't really see the point of making an Observable in the first place.
I changed the authHeader function to use firstValueFrom instead which works if the user navigates to the page that fetches the data but it doesn't work if the user refreshes the browser.
Revised authHeader function in fetch-wrapper.js
async function authHeader(url) {
// return auth header with jwt if user is logged in and request is to the api url
const user = await firstValueFrom(userService.user);
// Returns a user value if user navigates to the page
// Returns NULL if the page is refreshed
console.log("authHeader user", user);
const isLoggedIn = user && user.jwtToken;
const isApiUrl = url.startsWith(config.apiUrl);
if (isLoggedIn && isApiUrl) {
console.log("authHeader user.jwtToken", user.jwtToken);
return { Authorization: `Bearer ${user.jwtToken}` };
} else {
return {};
}
}
I also changed the post function so that the header component is created properly. Revised post function from fetch-wrapper.js
async function post(url, body) {
let headers = await authHeader(url);
headers = {
"Content-Type": "application/json",
...headers,
};
const requestOptions = {
method: "POST",
headers,
credentials: "include",
body: JSON.stringify(body),
};
const response = await fetch(url, requestOptions);
return handleResponse(response);
}
The userSubject is set when the user logs in and when the refreshToken function is called (code below).
function login(params) {
return fetchWrapper.post(`${baseUrl}/authenticate`, params).then((user) => {
if (!user || typeof user !== "object") {
throw new Error("Email or password is incorrect");
}
userSubject.next(user);
startRefreshTokenTimer();
return user;
});
}
function refreshToken() {
return fetchWrapper.post(`${baseUrl}/refresh-token`, {}).then((user) => {
if (user?.email) {
userSubject.next(user);
startRefreshTokenTimer();
return user;
}
});
}
function startRefreshTokenTimer() {
const jwtToken = JSON.parse(atob(userSubject.value.jwtToken.split(".")[1]));
const expires = new Date(jwtToken.exp * 1000);
const timeout = expires.getTime() - Date.now() - 60 * 1000;
refreshTokenTimeout = setTimeout(refreshToken, timeout);
}
It definitely has something to do with the RxJS userSubject not being available or something when the browser is refreshed. I created a new, stripped down app to test the functionality using the same userService.js and fetch-wrapper.js examples I provided originally. The commented out lines in the beginning are to run a silent refresh that I'll use once I get the issue resolved.
If the user is navigated to the loggedInPage, then the userSubject is availale to be queried. However, if the browser is refreshed on the loggedInPage, the userSubject returns null. Can anyone help me get this right? It very well could be that I'm using RxJS wrong but it used to work with previous apps I've made so I'm properly confused now.
TestApp.js
import React from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider } from "react-router";
import { createBrowserRouter, useNavigate } from "react-router";
import { Box, Button } from "@mui/material";
import { userService } from "./user.service";
const router = createBrowserRouter([
{
path: "",
element: <MainPage />,
},
{ path: "/user", element: <LoggedInPage /> },
]);
// attempt silent token refresh before startup
// userService
// .refreshToken()
// .then(() => {
// console.log("Silent token refresh successful");
// })
// .catch((error) => {
// console.warn("Silent token refresh failed:", error.message || error);
// userService.logout(); // Ensure user is logged out on failure
// })
// .finally(startApp);
startApp();
function startApp() {
ReactDOM.createRoot(document.getElementById("root")).render(
<RouterProvider router={router} />
);
}
function MainPage() {
const navigate = useNavigate();
const user = userService.userValue; // Get the current user
console.log("MainPage User:", user); // Always returns NULL as expected
const handleLogin = async () => {
try {
await userService.login({
email: "test@test.com",
password: "*******",
});
navigate("/user");
} catch (error) {
console.error("Login failed:", error);
}
};
return (
<Box>
Main page<Button onClick={() => handleLogin()}>Login</Button>
</Box>
);
}
function LoggedInPage() {
const navigate = useNavigate();
const user = userService.userValue; // Get the current user
console.log("LoggedInPage User:", user);
// Returns the user object if the page is navigated to from MainPage.js.
// Returns NULL if the browser is refreshed on loggedInPage.js
const handleLogout = async () => {
try {
await userService.logout();
navigate("/");
} catch (error) {
console.error("Logout failed:", error);
}
};
return (
<Box>
User logged in page<Button onClick={() => handleLogout()}>Logout</Button>
</Box>
);
}
RxJS does not persist streams of data across browser events (such as refreshes) on its own. This sort of behavior can only be achieved with some form of browser storage.
In the example below, the userSubject is initialized to the "username" item from local storage (which is just null if it hasn't yet been set). Values pushed to userSubject are added to local storage via setItem (as well as being rendered in the HTML). On page refreshes, the last stored value is retrieved and used as the initial value of the BehaviorSubject.
const { BehaviorSubject, filter } = rxjs;
const userSubject = new BehaviorSubject(localStorage.getItem("username"));
console.log(userSubject.value);
const displayEl = document.getElementById("username");
const inputEl = document.getElementById("newUsername");
userSubject.pipe(
filter(value => !!value)
).subscribe(value => {
localStorage.setItem("username", value);
displayEl.value = value;
});
document.getElementById("resetUsername").addEventListener("click", () => {
displayEl.value = null;
userSubject.next(null);
localStorage.clear();
});
document.getElementById("setUsername").addEventListener("click", () => {
userSubject.next(inputEl.value);
});
Username: <input id="username" disabled></input>
<hr>
<input id="newUsername" />
<button id="setUsername">Set username</button>
<button id="resetUsername">Reset</button>
<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/7.8.2/rxjs.umd.min.js" integrity="sha512-9rY+ul/tDd5uFmYIG3Wf6sTYh44qwK7ZUcAejPR1RzZ4I09ubJBeOoMvLtIUjXWN5ZfR8WgZx9gwnZhMQ0ve9A==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
A working example can be found on Codepen. Unfortunately, Stack Snippets do not support interacting with local storage, so I'm unsure how to recreate the behavior on Stack Overflow...