diff --git a/frontend/app/lib/auth.ts b/frontend/app/lib/auth.ts index f0f3908..8e29994 100644 --- a/frontend/app/lib/auth.ts +++ b/frontend/app/lib/auth.ts @@ -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 ───────────────────────────────────────────── @@ -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 { - if (!memoryRefreshToken) return null; - + const memoryRefreshToken = Cookies.get("memoryRefreshToken"); + if (!memoryRefreshToken || isTokenExpired(memoryRefreshToken)) { + clearTokens(); + return null; + } try { const data = await apiFetch("/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 { + const token = getAccessToken(); + if (token && !isTokenExpired(token)) return token; + return await refreshAccessToken(); } \ No newline at end of file diff --git a/frontend/app/routes.ts b/frontend/app/routes.ts index 954e38b..f66e06e 100644 --- a/frontend/app/routes.ts +++ b/frontend/app/routes.ts @@ -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; diff --git a/frontend/app/routes/authenticated.tsx b/frontend/app/routes/authenticated.tsx new file mode 100644 index 0000000..9ee9b79 --- /dev/null +++ b/frontend/app/routes/authenticated.tsx @@ -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

You are authenticated.

; +} \ No newline at end of file diff --git a/frontend/app/routes/login.tsx b/frontend/app/routes/login.tsx index 2b5c23b..65bcd09 100644 --- a/frontend/app/routes/login.tsx +++ b/frontend/app/routes/login.tsx @@ -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"; @@ -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", diff --git a/frontend/app/routes/protected-layout.tsx b/frontend/app/routes/protected-layout.tsx new file mode 100644 index 0000000..965d7f2 --- /dev/null +++ b/frontend/app/routes/protected-layout.tsx @@ -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 ; +} + +export default function ProtectedLayout() { + return ; +} \ No newline at end of file diff --git a/frontend/app/routes/signup.tsx b/frontend/app/routes/signup.tsx index cb22e90..317080e 100644 --- a/frontend/app/routes/signup.tsx +++ b/frontend/app/routes/signup.tsx @@ -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", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f4bd158..c00f7c1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "@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" @@ -16,6 +17,7 @@ "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", @@ -1317,6 +1319,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.20.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", @@ -2150,6 +2159,12 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0824000..ed9b83f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "@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" @@ -19,6 +20,7 @@ "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",