From 91d1616dd7e59cc18656a8fd5355ebab488e6fd7 Mon Sep 17 00:00:00 2001 From: Tetardtek Date: Tue, 24 Mar 2026 13:01:15 +0100 Subject: [PATCH] feat: PKCE auth + CI/CD deploy - Frontend: PKCE flow (oauth.js, api.js centralized, cookie-based AuthContext) - Backend: token introspection, cookies httpOnly, refresh endpoint - Replaced localStorage JWT with httpOnly session cookies - useSaveSync migrated to cookie auth - cookie-parser added - Gitea CI workflow (vps-runner pattern) --- .gitea/workflows/deploy.yml | 57 +++++ Backend/.env.sample | 7 + Backend/package-lock.json | 23 ++ Backend/package.json | 1 + Backend/src/app.js | 131 ++---------- Backend/src/controllers/authControllers.js | 177 ++++++++++++---- Backend/src/middlewares/verifyToken.js | 33 +-- Backend/src/router.js | 24 +-- Frontend/.env.sample | 7 +- Frontend/src/context/AuthContext.jsx | 231 +++++++-------------- Frontend/src/hooks/useSaveSync.ts | 42 ++-- Frontend/src/lib/api.js | 56 +++++ Frontend/src/lib/oauth.js | 83 ++++++++ Frontend/src/pages/AuthCallback.jsx | 54 ++++- Frontend/src/pages/Login.jsx | 15 +- 15 files changed, 548 insertions(+), 393 deletions(-) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 Frontend/src/lib/api.js create mode 100644 Frontend/src/lib/oauth.js diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..d4d24d6 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,57 @@ +name: CI/CD — Build & Deploy + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-and-deploy: + name: Build & Deploy + runs-on: vps-runner + + steps: + - uses: actions/checkout@v4 + + # ── Backend ────────────────────────────────────────────────────────────── + - name: Install backend deps + working-directory: Backend + run: npm ci --omit=dev + + - name: Deploy backend + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + mkdir -p /var/www/clickerz/backend + rsync -a --delete --exclude=node_modules --exclude=.env Backend/ /var/www/clickerz/backend/ + cd /var/www/clickerz/backend && npm ci --omit=dev + + - name: Restart pm2 + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + su - tetardtek-brain -c 'pm2 reload clickerz-backend --update-env' + + # ── Frontend ───────────────────────────────────────────────────────────── + - name: Install & build frontend + working-directory: Frontend + env: + VITE_BACKEND_URL: https://clickerz.tetardtek.com + VITE_OAUTH_URL: https://superoauth.tetardtek.com + VITE_OAUTH_CLIENT_ID: clickerz + run: | + npm ci + npm run build + + - name: Deploy frontend + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + mkdir -p /var/www/clickerz/frontend/dist + rsync -a --delete Frontend/dist/ /var/www/clickerz/frontend/dist/ + + # ── Smoke test ─────────────────────────────────────────────────────────── + - name: Smoke test API + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + sleep 3 + curl -sf http://localhost:3520/api/auth/me 2>&1 | grep -q '401\|session\|Not authenticated' + echo "✅ API responds OK" diff --git a/Backend/.env.sample b/Backend/.env.sample index 0b7bf47..a1b4ab4 100755 --- a/Backend/.env.sample +++ b/Backend/.env.sample @@ -3,6 +3,7 @@ # Application Configuration APP_PORT=3310 APP_SECRET=YOUR_APP_SECRET_KEY +NODE_ENV=development # Database Configuration DB_HOST=localhost @@ -13,3 +14,9 @@ DB_NAME=YOUR_DATABASE_NAME # Frontend URL (for CORS configuration) FRONTEND_URL=http://localhost:3000 + +# SuperOAuth — service externe d'authentification (introspection, pas de secret JWT) +SUPER_OAUTH_URL=https://superoauth.tetardtek.com + +# Cookie signing secret +COOKIE_SECRET= diff --git a/Backend/package-lock.json b/Backend/package-lock.json index 6e04816..229579c 100755 --- a/Backend/package-lock.json +++ b/Backend/package-lock.json @@ -6,6 +6,7 @@ "": { "dependencies": { "bcrypt": "^5.1.1", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.1", "express": "^4.18.2", @@ -333,6 +334,28 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", diff --git a/Backend/package.json b/Backend/package.json index 7c53586..b86cc24 100755 --- a/Backend/package.json +++ b/Backend/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "bcrypt": "^5.1.1", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.1", "express": "^4.18.2", diff --git a/Backend/src/app.js b/Backend/src/app.js index 48cbddd..ccee90a 100755 --- a/Backend/src/app.js +++ b/Backend/src/app.js @@ -1,86 +1,32 @@ -// Load the express module to create a web application - const express = require("express"); +const cors = require("cors"); +const cookieParser = require("cookie-parser"); const app = express(); -// Configure it - -/* ************************************************************************* */ - -// CORS Handling: Why is the current code commented out and do I need to define specific allowed origins for my project? - -// CORS (Cross-Origin Resource Sharing) is a security mechanism in web browsers that blocks requests from a different domain than the server. -// You may find the following magic line in forums: - -// app.use(cors()); - -// You should NOT do that: such code uses the `cors` module to allow all origins, which can pose security issues. -// For this pedagogical template, the CORS code is commented out to show the need for defining specific allowed origins. - -// To enable CORS and define allowed origins: -// 1. Install the `cors` module in the backend directory -// 2. Uncomment the line `const cors = require("cors");` -// 3. Uncomment the section `app.use(cors({ origin: [...] }))` -// 4. Be sure to only have URLs in the array with domains from which you want to allow requests. -// For example: ["http://mysite.com", "http://another-domain.com"] - -const cors = require("cors"); +// Trust reverse proxy (Apache on VPS) +app.set("trust proxy", 1); +// CORS — frontend + SuperOAuth origins app.use( cors({ origin: [ process.env.FRONTEND_URL, process.env.SUPER_OAUTH_URL, - ], + ].filter(Boolean), credentials: true, }) ); -/* ************************************************************************* */ - -// Request Parsing: Understanding the purpose of this part - -// Request parsing is necessary to extract data sent by the client in an HTTP request. -// For example to access the body of a POST request. -// The current code contains different parsing options as comments to demonstrate different ways of extracting data. - -// 1. `express.json()`: Parses requests with JSON data. -// 2. `express.urlencoded()`: Parses requests with URL-encoded data. -// 3. `express.text()`: Parses requests with raw text data. -// 4. `express.raw()`: Parses requests with raw binary data. - -// Uncomment one or more of these options depending on the format of the data sent by your client: +// Cookie parser with signing +const cookieSecret = process.env.COOKIE_SECRET; +if (!cookieSecret) { + console.error("COOKIE_SECRET manquant — cookies non signés !"); +} +app.use(cookieParser(cookieSecret)); +// JSON body parser app.use(express.json()); -// app.use(express.urlencoded()); -// app.use(express.text()); -// app.use(express.raw()); - -/* ************************************************************************* */ - -// Cookies: Why and how to use the `cookie-parser` module? - -// Cookies are small pieces of data stored in the client's browser. They are often used to store user-specific information or session data. - -// The `cookie-parser` module allows us to parse and manage cookies in our Express application. It parses the `Cookie` header in incoming requests and populates `req.cookies` with an object containing the cookies. - -// To use `cookie-parser`, make sure it is installed in `backend/package.json` (you may need to install it separately): -// npm install cookie-parser - -// Then, require the module and use it as middleware in your Express application: - -// const cookieParser = require("cookie-parser"); -// app.use(cookieParser()); - -// Once `cookie-parser` is set up, you can read and set cookies in your routes. -// For example, to set a cookie named "username" with the value "john": -// res.cookie("username", "john"); - -// To read the value of a cookie named "username": -// const username = req.cookies.username; - -/* ************************************************************************* */ // Import the API routes from the router module const path = require("path"); @@ -89,35 +35,7 @@ const router = require("./router"); // Mount the API routes under the "/api" endpoint app.use("/api", router); -/* ************************************************************************* */ - -// Production-ready setup: What is it for, and when should I enable it? - -// The code includes commented sections to set up a production environment where the frontend and backend are served from the same server. - -// What it's for: -// - Serving frontend static files from the backend, which is useful when building a single-page application with React, Angular, etc. -// - Redirecting unhandled requests (e.g., all requests not matching a defined API route) to the frontend's index.html. This allows the frontend to handle client-side routing. - -// When to enable it: -// It depends on your project and its structure. If you are developing a single-page application, you'll enable these sections when you are ready to deploy your project to production. - -// To enable production configuration: -// 1. Uncomment the lines related to serving static files and redirecting unhandled requests. -// 2. Ensure that the `reactBuildPath` points to the correct directory where your frontend's build artifacts are located. - -// const reactBuildPath = `${__dirname}/../../frontend/dist`; - -// // Serve react resources - -// app.use(express.static(reactBuildPath)); - -// // Redirect unhandled requests to the react index file - -// app.get("*", (req, res) => { -// res.sendFile(`${reactBuildPath}/index.html`); -// }); - +// Serve frontend static files in production app.use("*", (req, res) => { if (req.originalUrl.includes("assets")) { res.sendFile( @@ -127,26 +45,5 @@ app.use("*", (req, res) => { res.sendFile(path.resolve(__dirname, `../../frontend/dist/index.html`)); } }); -/* ************************************************************************* */ - -// Middleware for Error Logging (Uncomment to enable) -// Important: Error-handling middleware should be defined last, after other app.use() and routes calls. - -/* -// Define a middleware function to log errors -const logErrors = (err, req, res, next) => { - // Log the error to the console for debugging purposes - console.error(err); - console.error("on req:", req.method, req.path); - - // Pass the error to the next middleware in the stack - next(err); -}; - -// Mount the logErrors middleware globally -app.use(logErrors); -*/ - -/* ************************************************************************* */ module.exports = app; diff --git a/Backend/src/controllers/authControllers.js b/Backend/src/controllers/authControllers.js index ad62a5c..151856f 100644 --- a/Backend/src/controllers/authControllers.js +++ b/Backend/src/controllers/authControllers.js @@ -1,78 +1,175 @@ -const jwt = require("jsonwebtoken"); const tables = require("../tables"); -const secretKey = process.env.APP_SECRET; +const SUPER_OAUTH_URL = process.env.SUPER_OAUTH_URL; +const COOKIE_NAME = "session"; +const REFRESH_COOKIE_NAME = "refresh_token"; -/** - * GET /api/auth/callback?code= - * - * Reçoit le token SuperOAuth depuis le frontend après redirect OAuth. - * Valide auprès de SuperOAuth, résout ou crée le user local, retourne un JWT local. - */ -const callback = async (req, res) => { - const { code } = req.query; +function cookieOptions(maxAgeDays) { + const isProduction = process.env.NODE_ENV === "production"; + return { + httpOnly: true, + signed: true, + secure: isProduction, + sameSite: "lax", + maxAge: maxAgeDays * 24 * 60 * 60 * 1000, + }; +} - if (!code) { - return res.status(400).json({ message: "Missing OAuth code." }); +async function introspectToken(token) { + if (!SUPER_OAUTH_URL) { + throw new Error("SUPER_OAUTH_URL not configured"); } - const superOAuthUrl = process.env.SUPER_OAUTH_URL; - if (!superOAuthUrl) { - return res.status(500).json({ message: "Auth service not configured." }); + const response = await fetch(`${SUPER_OAUTH_URL}/api/v1/auth/token/validate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }); + + const data = await response.json(); + + if (!response.ok || !data.data?.valid || !data.data?.user) { + return null; + } + + if (!data.data.user.isActive) { + return null; + } + + return data.data.user; +} + +/** + * POST /api/auth/session + * Receives { token, refreshToken? } from frontend after PKCE exchange. + * Introspects token via SuperOAuth, upserts local user, sets httpOnly cookies. + */ +const session = async (req, res) => { + const { token, refreshToken } = req.body; + + if (!token) { + return res.status(400).json({ message: "Missing token." }); } try { - const response = await fetch(`${superOAuthUrl}/api/v1/auth/token/validate`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: code }), - }); - - const data = await response.json(); - - if (!response.ok || !data.data?.valid || !data.data?.user) { + const oauthUser = await introspectToken(token); + if (!oauthUser) { return res.status(401).json({ message: "Invalid OAuth token." }); } - if (!data.data.user.isActive) { - return res.status(401).json({ message: "Account is disabled." }); - } - - const { id: superOAuthId, email, nickname } = data.data.user; + const { id: superOAuthId, email, nickname } = oauthUser; let localUser = await tables.users.getBySuperOAuthId(superOAuthId); if (!localUser) { - // Premier login OAuth — créer le compte local const insertId = await tables.users.create({ nickname: nickname ?? email?.split("@")[0] ?? `user_${Date.now()}`, mail: email ?? `${superOAuthId}@oauth.local`, - password: "", // pas de password local — auth via SuperOAuth uniquement + password: "", tetardcoin: 1000, }); await tables.users.linkSuperOAuth(insertId, superOAuthId); localUser = await tables.users.read(insertId); } - const token = jwt.sign({ user: localUser.id }, secretKey, { expiresIn: "7d" }); + // Set session cookie + res.cookie(COOKIE_NAME, String(localUser.id), cookieOptions(7)); - return res.status(200).json({ - message: "Connexion réussie", - user: localUser, - token, - }); + // Set refresh token cookie if provided + if (refreshToken) { + res.cookie(REFRESH_COOKIE_NAME, refreshToken, cookieOptions(30)); + } + + return res.status(200).json({ user: localUser }); } catch (err) { - console.error("auth/callback — error", err); + console.error("auth/session — error", err); + return res.status(500).json({ message: "Internal server error." }); + } +}; + +/** + * POST /api/auth/refresh + * Reads refresh_token from cookie, exchanges for new access token via SuperOAuth. + */ +const refresh = async (req, res) => { + const refreshToken = req.signedCookies?.[REFRESH_COOKIE_NAME]; + if (!refreshToken) { + return res.status(401).json({ message: "No refresh token." }); + } + + try { + const response = await fetch(`${SUPER_OAUTH_URL}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + }).toString(), + }); + + if (!response.ok) { + return res.status(401).json({ message: "Refresh token invalid or expired." }); + } + + const data = await response.json(); + if (!data.access_token) { + return res.status(401).json({ message: "Refresh failed." }); + } + + // Validate new token + const oauthUser = await introspectToken(data.access_token); + if (!oauthUser) { + return res.status(401).json({ message: "Refreshed token invalid." }); + } + + const localUser = await tables.users.getBySuperOAuthId(oauthUser.id); + if (!localUser) { + return res.status(401).json({ message: "User not found." }); + } + + // Set new cookies + res.cookie(COOKIE_NAME, String(localUser.id), cookieOptions(7)); + if (data.refresh_token) { + res.cookie(REFRESH_COOKIE_NAME, data.refresh_token, cookieOptions(30)); + } + + return res.status(200).json({ success: true }); + } catch (err) { + console.error("auth/refresh — error", err); + return res.status(500).json({ message: "Internal server error." }); + } +}; + +/** + * GET /api/auth/me + * Returns current user from session cookie. + */ +const me = async (req, res) => { + const userId = req.signedCookies?.[COOKIE_NAME]; + if (!userId) { + return res.status(401).json({ message: "Not authenticated." }); + } + + try { + const user = await tables.users.read(parseInt(userId, 10)); + if (!user) { + return res.status(401).json({ message: "User not found." }); + } + return res.status(200).json(user); + } catch (err) { + console.error("auth/me — error", err); return res.status(500).json({ message: "Internal server error." }); } }; /** * POST /api/auth/logout - * Stateless — le token JWT est géré côté client. + * Clears session cookies. */ const logout = (_req, res) => { + res.clearCookie(COOKIE_NAME); + res.clearCookie(REFRESH_COOKIE_NAME); return res.status(200).json({ message: "Déconnexion réussie." }); }; -module.exports = { callback, logout }; +module.exports = { session, refresh, me, logout }; diff --git a/Backend/src/middlewares/verifyToken.js b/Backend/src/middlewares/verifyToken.js index 146035d..e4ea559 100755 --- a/Backend/src/middlewares/verifyToken.js +++ b/Backend/src/middlewares/verifyToken.js @@ -1,23 +1,26 @@ -const jwt = require("jsonwebtoken"); +const tables = require("../tables"); -const secretKey = process.env.APP_SECRET; +/** + * Cookie-based auth middleware. + * Reads signed session cookie → resolves local user → sets req.user = user.id + */ +const verifyToken = async (req, res, next) => { + const userId = req.signedCookies?.session; -const verifyToken = (req, res, next) => { - const token = req.header("x-auth-token"); - - if (!token) { - return res - .status(401) - .json({ message: "Access denied. No token provided." }); + if (!userId) { + return res.status(401).json({ message: "Access denied. No session." }); } try { - const decoded = jwt.verify(token, secretKey); - req.user = decoded.user; - next(); - return null; - } catch (error) { - return res.status(401).json({ message: "Invalid token." }); + const user = await tables.users.read(parseInt(userId, 10)); + if (!user) { + return res.status(401).json({ message: "User not found." }); + } + req.user = user.id; + return next(); + } catch (err) { + console.error("verifyToken — error", err); + return res.status(500).json({ message: "Internal server error." }); } }; diff --git a/Backend/src/router.js b/Backend/src/router.js index 7e0c422..3c79848 100755 --- a/Backend/src/router.js +++ b/Backend/src/router.js @@ -2,18 +2,13 @@ const express = require("express"); const router = express.Router(); -/* ************************************************************************* */ -// Define Your API Routes Here -/* ************************************************************************* */ - // Import Controllers const userControllers = require("./controllers/userControllers"); const authControllers = require("./controllers/authControllers"); const saveControllers = require("./controllers/saveControllers"); const verifyToken = require("./middlewares/verifyToken"); -const verifyOAuth = require("./middlewares/verifyOAuth"); -// Vérifie que le token appartient au même utilisateur que :id +// Vérifie que le cookie session appartient au même utilisateur que :id const verifySelf = (req, res, next) => { if (String(req.user) !== String(req.params.id)) { return res.status(403).json({ message: "Forbidden." }); @@ -21,11 +16,13 @@ const verifySelf = (req, res, next) => { return next(); }; -// Auth SuperOAuth -router.get("/auth/callback", authControllers.callback); +// Auth — PKCE flow (cookie-based) +router.post("/auth/session", authControllers.session); +router.post("/auth/refresh", authControllers.refresh); +router.get("/auth/me", authControllers.me); router.post("/auth/logout", authControllers.logout); -// User management (auth locale — conservée pendant migration) +// User management router.get("/users", verifyToken, userControllers.browse); router.get("/users/:id", verifyToken, verifySelf, userControllers.read); router.get("/users/:id/field", verifyToken, verifySelf, userControllers.read); @@ -34,14 +31,11 @@ router.post("/users", userControllers.add); router.delete("/users/:id", verifyToken, verifySelf, userControllers.destroy); router.post("/login", userControllers.login); -// Sync game state — SuperOAuth uniquement -router.patch("/users/:id/coins", verifyOAuth, verifySelf, userControllers.updateCoins); +// Sync game state — cookie auth (was verifyOAuth, now same as verifyToken) +router.patch("/users/:id/coins", verifyToken, verifySelf, userControllers.updateCoins); -// Game saves — JWT required +// Game saves — cookie auth router.get("/save", verifyToken, saveControllers.load); router.post("/save", verifyToken, saveControllers.save); - -/* ************************************************************************* */ - module.exports = router; diff --git a/Frontend/.env.sample b/Frontend/.env.sample index d04457a..6c8d6e0 100755 --- a/Frontend/.env.sample +++ b/Frontend/.env.sample @@ -1,7 +1,8 @@ # .env.sample - Sample Environment Variables for Frontend (Vite) -# Backend API URL (call it in React with import.meta.env.VITE_BACKEND_URL) +# Backend API URL VITE_BACKEND_URL=http://localhost:3310 -# SuperOAuth URL (OAuth login provider) -VITE_SUPEROAUTH_URL=https://superoauth.tetardtek.com +# SuperOAuth PKCE — OAuth provider +VITE_OAUTH_URL=https://superoauth.tetardtek.com +VITE_OAUTH_CLIENT_ID=clickerz diff --git a/Frontend/src/context/AuthContext.jsx b/Frontend/src/context/AuthContext.jsx index e9110f6..8e8d6a1 100755 --- a/Frontend/src/context/AuthContext.jsx +++ b/Frontend/src/context/AuthContext.jsx @@ -1,166 +1,79 @@ -import React, { - createContext, - useContext, - useState, - useMemo, - useEffect, - } from "react"; - import PropTypes from "prop-types"; - const decodeJwtPayload = (token) => - JSON.parse(atob(token.split(".")[1])); +import React, { createContext, useContext, useState, useMemo, useEffect } from "react"; +import PropTypes from "prop-types"; +import { apiFetch } from "../lib/api"; - const AuthContext = createContext(); +const AuthContext = createContext(); - const AuthProvider = ({ children }) => { - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); +const AuthProvider = ({ children }) => { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); - useEffect(() => { - const fetchData = async () => { - const jwtToken = localStorage.getItem("token"); - - if (jwtToken) { - try { - const decodedPayload = decodeJwtPayload(jwtToken); - const res = await fetch( - `${import.meta.env.VITE_BACKEND_URL}/api/users/${decodedPayload.user}`, - { - headers: { "x-auth-token": jwtToken }, - } - ); - if (!res.ok) throw new Error("Failed to fetch user"); - const data = await res.json(); - setUser(data); - } catch (error) { - console.error("Error fetching user data:", error); - localStorage.removeItem("token"); - } finally { - setLoading(false); - } - } else { - setLoading(false); - } - }; - - fetchData(); - }, []); - - const loginWithOAuth = async (token) => { - const res = await fetch( - `${import.meta.env.VITE_BACKEND_URL}/api/auth/callback?code=${encodeURIComponent(token)}` - ); - const data = await res.json(); - - if (!res.ok) { - throw new Error(data.message || "OAuth login failed"); - } - - localStorage.setItem("token", data.token); - setUser(data.user); - return data.user; - }; - - const logout = () => { - localStorage.removeItem("token"); + const refresh = async () => { + try { + const data = await apiFetch("/auth/me"); + setUser(data); + } catch { setUser(null); - }; - - const editUser = async (updatedFields) => { - try { - const response = await fetch( - `${import.meta.env.VITE_BACKEND_URL}/api/users/${user.id}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - "x-auth-token": localStorage.getItem("token"), - }, - body: JSON.stringify(updatedFields), - } - ); - - if (response.ok) { - const updatedUser = await response.json(); - setUser((prevUser) => ({ - ...prevUser, - ...updatedUser.user, - })); - return "User updated successfully"; - } - if (response.status === 400) { - console.error("Bad Request:", response.statusText); - throw new Error("Bad Request"); - } else if (response.status === 401) { - console.error("Unauthorized:", response.statusText); - throw new Error("Unauthorized"); - } else { - console.error("Error updating user:", response.statusText); - throw new Error("Error updating user"); - } - } catch (error) { - console.error("Error updating user:", error); - throw new Error("An error occurred during user update"); - } - }; - - const sendPasswordResetEmail = async (email) => { - try { - const response = await fetch( - `${import.meta.env.VITE_BACKEND_URL}/api/forgot-password`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ mail: email }), - } - ); - - if (response.ok) { - return "Password reset email sent successfully"; - } - - const data = await response.json(); - throw new Error(data.message || "Error sending password reset email"); - } catch (error) { - console.error("Error sending password reset email:", error); - throw new Error( - "An error occurred while sending the password reset email" - ); - } - }; - - const authContextValue = useMemo(() => { - return { - user, - loading, - logout, - loginWithOAuth, - editUser, - sendPasswordResetEmail, - setUser: (newUser) => { - setUser(newUser); - }, - }; - }, [user, loading, logout]); - - return ( - - {children} - - ); - }; - - AuthProvider.propTypes = { - children: PropTypes.node.isRequired, - }; - - const useAuth = () => { - const context = useContext(AuthContext); - if (!context) { - throw new Error("useAuth must be used within an AuthProvider"); } - return context; }; - export { AuthProvider, useAuth }; + useEffect(() => { + refresh().finally(() => setLoading(false)); + }, []); + + useEffect(() => { + const onExpired = () => setUser(null); + window.addEventListener("auth:expired", onExpired); + return () => window.removeEventListener("auth:expired", onExpired); + }, []); + + const logout = async () => { + try { + await apiFetch("/auth/logout", { method: "POST" }); + } catch { + // ignore + } + setUser(null); + }; + + const editUser = async (updatedFields) => { + const data = await apiFetch(`/users/${user.id}`, { + method: "PUT", + body: JSON.stringify(updatedFields), + }); + setUser((prev) => ({ ...prev, ...data.user })); + return "User updated successfully"; + }; + + const authContextValue = useMemo( + () => ({ + user, + loading, + logout, + refresh, + editUser, + setUser: (newUser) => setUser(newUser), + }), + [user, loading] + ); + + return ( + + {children} + + ); +}; + +AuthProvider.propTypes = { + children: PropTypes.node.isRequired, +}; + +const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +}; + +export { AuthProvider, useAuth }; diff --git a/Frontend/src/hooks/useSaveSync.ts b/Frontend/src/hooks/useSaveSync.ts index b00c155..52e70a2 100644 --- a/Frontend/src/hooks/useSaveSync.ts +++ b/Frontend/src/hooks/useSaveSync.ts @@ -1,8 +1,8 @@ // useSaveSync.ts — Auto-save game state to backend every 30s -// Requires JWT token in localStorage (set by auth flow) -// Falls back silently if no token (guest mode) +// Cookie-based auth — credentials sent automatically import { useEffect, useRef, useCallback } from "react"; +import { useAuth } from "../context/AuthContext"; import type { GameState } from "../core/economy"; const SAVE_INTERVAL_MS = 30_000; // 30 seconds @@ -15,16 +15,13 @@ interface SaveSyncOptions { } async function apiRequest(path: string, options: RequestInit = {}) { - const token = localStorage.getItem("token"); - if (!token) return null; - const res = await fetch(`${BACKEND_URL}/api${path}`, { - ...options, + credentials: "include", headers: { "Content-Type": "application/json", - "x-auth-token": token, ...options.headers, }, + ...options, }); if (!res.ok) { @@ -37,17 +34,15 @@ async function apiRequest(path: string, options: RequestInit = {}) { } export function useSaveSync({ getGameState, onLoad, playTimeSeconds }: SaveSyncOptions) { + const { user } = useAuth(); const lastSaveRef = useRef(null); const loadedRef = useRef(false); // Load save on mount (once) useEffect(() => { - if (loadedRef.current) return; + if (loadedRef.current || !user) return; loadedRef.current = true; - const token = localStorage.getItem("token"); - if (!token) return; - apiRequest("/save").then((data) => { if (data?.gameState) { onLoad(data.gameState); @@ -55,12 +50,11 @@ export function useSaveSync({ getGameState, onLoad, playTimeSeconds }: SaveSyncO console.info("[SaveSync] Loaded save from server"); } }); - }, [onLoad]); + }, [onLoad, user]); // Save function const saveToServer = useCallback(async () => { - const token = localStorage.getItem("token"); - if (!token) return; + if (!user) return; const gameState = getGameState(); const result = await apiRequest("/save", { @@ -71,37 +65,31 @@ export function useSaveSync({ getGameState, onLoad, playTimeSeconds }: SaveSyncO if (result?.lastSave) { lastSaveRef.current = result.lastSave; } - }, [getGameState, playTimeSeconds]); + }, [getGameState, playTimeSeconds, user]); // Auto-save interval useEffect(() => { - const token = localStorage.getItem("token"); - if (!token) return undefined; + if (!user) return undefined; const interval = setInterval(() => { saveToServer(); }, SAVE_INTERVAL_MS); return () => clearInterval(interval); - }, [saveToServer]); + }, [saveToServer, user]); // Save on page unload useEffect(() => { const handleUnload = () => { - const token = localStorage.getItem("token"); - if (!token) return; + if (!user) return; const gameState = getGameState(); const payload = JSON.stringify({ gameState, playTimeSeconds }); - // Use fetch with keepalive for reliable save on tab close - // (sendBeacon doesn't support custom headers) fetch(`${BACKEND_URL}/api/save`, { method: "POST", - headers: { - "Content-Type": "application/json", - "x-auth-token": token, - }, + credentials: "include", + headers: { "Content-Type": "application/json" }, body: payload, keepalive: true, }).catch(() => {}); @@ -109,7 +97,7 @@ export function useSaveSync({ getGameState, onLoad, playTimeSeconds }: SaveSyncO window.addEventListener("beforeunload", handleUnload); return () => window.removeEventListener("beforeunload", handleUnload); - }, [getGameState, playTimeSeconds]); + }, [getGameState, playTimeSeconds, user]); return { saveToServer, lastSave: lastSaveRef.current }; } diff --git a/Frontend/src/lib/api.js b/Frontend/src/lib/api.js new file mode 100644 index 0000000..467ab0a --- /dev/null +++ b/Frontend/src/lib/api.js @@ -0,0 +1,56 @@ +// Centralized API client — cookie-based auth with 401 auto-refresh + +const BASE = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3310'; + +let refreshPromise = null; + +async function tryRefresh() { + if (refreshPromise) return refreshPromise; + refreshPromise = (async () => { + try { + const res = await fetch(`${BASE}/api/auth/refresh`, { + method: 'POST', + credentials: 'include', + }); + return res.ok; + } catch { + return false; + } finally { + refreshPromise = null; + } + })(); + return refreshPromise; +} + +export async function apiFetch(path, options = {}) { + const res = await fetch(`${BASE}/api${path}`, { + credentials: 'include', + headers: { 'Content-Type': 'application/json', ...options.headers }, + ...options, + }); + + if (res.status === 401 && path !== '/auth/refresh') { + const refreshed = await tryRefresh(); + if (refreshed) { + const retry = await fetch(`${BASE}/api${path}`, { + credentials: 'include', + headers: { 'Content-Type': 'application/json', ...options.headers }, + ...options, + }); + if (retry.ok) { + if (retry.status === 204) return null; + return retry.json(); + } + } + window.dispatchEvent(new Event('auth:expired')); + throw new Error('Session expired'); + } + + if (!res.ok) { + const body = await res.json().catch(() => ({ message: res.statusText })); + throw new Error(body.message || `HTTP ${res.status}`); + } + + if (res.status === 204) return null; + return res.json(); +} diff --git a/Frontend/src/lib/oauth.js b/Frontend/src/lib/oauth.js new file mode 100644 index 0000000..fdd27d6 --- /dev/null +++ b/Frontend/src/lib/oauth.js @@ -0,0 +1,83 @@ +// OAuth 2.0 PKCE client — SuperOAuth consumer for Clickerz + +const OAUTH_URL = import.meta.env.VITE_OAUTH_URL || ''; +const OAUTH_CLIENT_ID = import.meta.env.VITE_OAUTH_CLIENT_ID || ''; + +const SESSION_KEY_VERIFIER = 'clkz_pkce_verifier'; + +function base64UrlEncode(buffer) { + return btoa(String.fromCharCode(...new Uint8Array(buffer))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +export function generateCodeVerifier() { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return base64UrlEncode(array.buffer); +} + +export async function generateCodeChallenge(verifier) { + const data = new TextEncoder().encode(verifier); + const digest = await crypto.subtle.digest('SHA-256', data); + return base64UrlEncode(digest); +} + +export async function buildAuthUrl(redirectUri, provider, scope = 'openid profile email', clientId = OAUTH_CLIENT_ID) { + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)).buffer); + + const params = new URLSearchParams({ + response_type: 'code', + client_id: clientId, + redirect_uri: redirectUri, + scope, + state, + provider, + code_challenge: challenge, + code_challenge_method: 'S256', + }); + + return { + url: `${OAUTH_URL}/oauth/authorize?${params.toString()}`, + verifier, + }; +} + +export async function exchangeCode(code, verifier, redirectUri, clientId = OAUTH_CLIENT_ID) { + const response = await fetch(`${OAUTH_URL}/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: clientId, + code, + code_verifier: verifier, + redirect_uri: redirectUri, + }).toString(), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`OAuth token exchange failed (${response.status}): ${text}`); + } + + const data = await response.json(); + if (!data.access_token) throw new Error('No access_token in OAuth response'); + + return data; +} + +export function saveVerifier(verifier) { + sessionStorage.setItem(SESSION_KEY_VERIFIER, verifier); +} + +export function loadVerifier() { + return sessionStorage.getItem(SESSION_KEY_VERIFIER); +} + +export function clearVerifier() { + sessionStorage.removeItem(SESSION_KEY_VERIFIER); +} diff --git a/Frontend/src/pages/AuthCallback.jsx b/Frontend/src/pages/AuthCallback.jsx index f8dead6..dec186e 100644 --- a/Frontend/src/pages/AuthCallback.jsx +++ b/Frontend/src/pages/AuthCallback.jsx @@ -1,26 +1,60 @@ -import { useEffect, useState } from "react"; -import { useNavigate, useSearchParams, Link } from "react-router-dom"; +import { useEffect, useState, useRef } from "react"; +import { useNavigate, Link } from "react-router-dom"; +import { exchangeCode, loadVerifier, clearVerifier } from "../lib/oauth"; +import { apiFetch } from "../lib/api"; import { useAuth } from "../context/AuthContext"; import "../scss/pages.scss"; export default function AuthCallback() { - const [searchParams] = useSearchParams(); - const { loginWithOAuth } = useAuth(); const navigate = useNavigate(); + const { refresh } = useAuth(); const [error, setError] = useState(null); + const called = useRef(false); useEffect(() => { - const token = searchParams.get("token"); + if (called.current) return; + called.current = true; - if (!token) { - setError("Token manquant dans l'URL."); + const params = new URLSearchParams(window.location.search); + const code = params.get("code"); + const err = params.get("error"); + + if (err) { + setError(err); return; } - loginWithOAuth(token) + if (!code) { + setError("Code manquant dans l'URL."); + return; + } + + const verifier = loadVerifier(); + if (!verifier) { + setError("Verifier PKCE manquant — réessaie la connexion."); + return; + } + + const redirectUri = `${window.location.origin}/callback`; + + exchangeCode(code, verifier, redirectUri) + .then((tokens) => { + clearVerifier(); + return apiFetch("/auth/session", { + method: "POST", + body: JSON.stringify({ + token: tokens.access_token, + refreshToken: tokens.refresh_token, + }), + }); + }) + .then(() => refresh()) .then(() => navigate("/", { replace: true })) - .catch((err) => setError(err.message || "Erreur de connexion.")); - }, []); + .catch((e) => { + clearVerifier(); + setError(e.message || "Erreur de connexion."); + }); + }, [navigate, refresh]); if (error) { return ( diff --git a/Frontend/src/pages/Login.jsx b/Frontend/src/pages/Login.jsx index c502a24..af4171e 100644 --- a/Frontend/src/pages/Login.jsx +++ b/Frontend/src/pages/Login.jsx @@ -1,10 +1,9 @@ import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { useAuth } from "../context/AuthContext"; +import { buildAuthUrl, saveVerifier } from "../lib/oauth"; import "../scss/pages.scss"; -const SUPEROAUTH_URL = import.meta.env.VITE_SUPEROAUTH_URL; - export default function Login() { const { user } = useAuth(); const navigate = useNavigate(); @@ -13,9 +12,11 @@ export default function Login() { if (user) navigate("/", { replace: true }); }, [user, navigate]); - const handleLogin = () => { - const callbackUrl = `${window.location.origin}/callback`; - window.location.href = `${SUPEROAUTH_URL}/api/v1/oauth/discord?redirectUrl=${encodeURIComponent(callbackUrl)}&tenantId=clickerz`; + const handleLogin = async (provider = "discord") => { + const redirectUri = `${window.location.origin}/callback`; + const { url, verifier } = await buildAuthUrl(redirectUri, provider); + saveVerifier(verifier); + window.location.href = url; }; return ( @@ -23,8 +24,8 @@ export default function Login() {

Connexion

Connecte-toi pour sauvegarder ta progression.

-