Skip to content
Merged
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
54 changes: 46 additions & 8 deletions frontend/app/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,40 @@
*
* Pattern:
* - Access token: Saved in-memory (JS variable). Safest route to prevent XSS sniffing.
* - Refresh token: Saved in HttpOnly cookie by backend. JS has no access to it.
* - Refresh token: Saved in Cookie by using js-cookie. JS has access to it.
*/

import Cookies from "js-cookie";
import { apiFetch } from "./api";
import type { AuthData, AuthTokens } from "./types";

// ─── Token storage (In-memory) ───────────────────────────────────

let memoryAccessToken: string | null = null;
let memoryRefreshToken: string | null = null;

export function getAccessToken(): string | null {
return memoryAccessToken;
}

export function getRefreshToken(): string | null {
const memoryRefreshToken = Cookies.get("memoryRefreshToken");
if (memoryRefreshToken){
return memoryRefreshToken;
}
return null;
}

export function storeTokens(tokens: AuthTokens): void {
memoryAccessToken = tokens.access;
memoryRefreshToken = tokens.refresh
Cookies.set("memoryRefreshToken", tokens.refresh, {
expires: 1,
secure: true,
sameSite: "strict",
});
}

export function clearTokens(): void {
memoryAccessToken = null;
memoryRefreshToken = null;
Cookies.remove("memoryRefreshToken");
}

// ─── Auth operations ─────────────────────────────────────────────
Expand Down Expand Up @@ -58,23 +69,50 @@ export async function signup(
return data.token;
}


/**
* Attempts to get a fresh access token using the stored refresh token.
* Used after a page reload wipes the in-memory access token.
*/
export async function refreshAccessToken(): Promise<string | null> {
if (!memoryRefreshToken) return null;

const memoryRefreshToken = Cookies.get("memoryRefreshToken");
if (!memoryRefreshToken || isTokenExpired(memoryRefreshToken)) {
clearTokens();
return null;
}
try {
const data = await apiFetch<AuthData>("/auth/token/refresh/", {
method: "POST",
body: { refresh: memoryRefreshToken },
});
memoryAccessToken = data.token.access;
if (data.token.refresh) {
Cookies.set("memoryRefreshToken", data.token.refresh);
}
return memoryAccessToken;
} catch {
clearTokens();
return null;
}
}

/**
* Check if the stored token is expired or not
*/
function isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.exp * 1000 < Date.now();
} catch {
return true;
}
}

/**
* Returns a valid access token, trying memory first then refresh token.
* Used by route loaders to check auth status.
*/
export async function getValidAccessToken(): Promise<string | null> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

a valid access token means a token that is actually functioning, this function is only checking whether the token is present or no

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Try checking if the token is actually valid through a quick backend call

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added a small function called isTokenExpired that decodes the JWT and compares the exp field to the current time. This gives us the same result as calling the backend would, but we skip the extra network call on every route load, so it's faster. Backend still does its own validation on real API calls, so nothing is less secure, we're just avoiding an unnecessary check upfront.

const token = getAccessToken();
if (token && !isTokenExpired(token)) return token;
return await refreshAccessToken();
}
5 changes: 4 additions & 1 deletion frontend/app/routes.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { type RouteConfig, index, route } from "@react-router/dev/routes";
import { type RouteConfig, layout, index, route } from "@react-router/dev/routes";

export default [
index("routes/home.tsx"),
route("login", "routes/login.tsx"),
route("signup", "routes/signup.tsx"),
layout("routes/protected-layout.tsx", [
route("auth", "routes/authenticated.tsx"),
]),
route("health", "routes/health.tsx"),
] satisfies RouteConfig;
14 changes: 14 additions & 0 deletions frontend/app/routes/authenticated.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Temporary cuthentication checking tab

import type { Route } from "./+types/authenticated";

export function meta({}: Route.MetaArgs) {
return [
{ title: "Authenticated" },
{ name: "description", content: "User is authenticated" },
];
}

export default function AuthenticatedRoute() {
return <p>You are authenticated.</p>;
}
4 changes: 2 additions & 2 deletions frontend/app/routes/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* - meta: sets browser tab title
* - default export: renders LoginForm with any action errors
*
* Uses clientAction (not server action) because storeTokens uses sessionStorage (browser-only).
* Uses clientAction (not server action) because storeTokens uses js-cookie (browser-only).
*/

import { redirect, useActionData, useNavigation } from "react-router";
Expand All @@ -28,7 +28,7 @@ export async function clientAction({ request }: Route.ClientActionArgs) {
try {
const tokens = await login(email, password);
storeTokens(tokens);
return redirect("/");
return redirect("/auth");
} catch (err) {
return {
error: err instanceof Error ? err.message : "Something went wrong",
Expand Down
21 changes: 21 additions & 0 deletions frontend/app/routes/protected-layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// routes/protected-layout.tsx
import { Navigate, Outlet } from "react-router";
import type { LoaderFunctionArgs } from "react-router";
import { getAccessToken, getValidAccessToken } from "~/lib/auth";

export async function clientLoader({ request }: LoaderFunctionArgs) {
const token = await getValidAccessToken();
if (!token) {
throw new Response("Unauthorized", { status: 401 });
}

return { token };
}

export function ErrorBoundary() {
return <Navigate to="/login" replace />;
}

export default function ProtectedLayout() {
return <Outlet />;
}
2 changes: 1 addition & 1 deletion frontend/app/routes/signup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export async function clientAction({ request }: Route.ClientActionArgs) {
try {
const tokens = await signup(email, password, confirmPassword);
storeTokens(tokens);
return redirect("/");
return redirect("/login");
} catch (err) {
return {
error: err instanceof Error ? err.message : "Something went wrong",
Expand Down
15 changes: 15 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@
"@react-router/node": "^8",
"@react-router/serve": "^8",
"isbot": "^5.1.36",
"js-cookie": "^3.0.8",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router": "^8"
},
"devDependencies": {
"@react-router/dev": "^8",
"@tailwindcss/vite": "^4.2.2",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
Expand Down