feat: PKCE auth + CI/CD deploy
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 25s
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 25s
- 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)
This commit is contained in:
57
.gitea/workflows/deploy.yml
Normal file
57
.gitea/workflows/deploy.yml
Normal file
@@ -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"
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
# Application Configuration
|
# Application Configuration
|
||||||
APP_PORT=3310
|
APP_PORT=3310
|
||||||
APP_SECRET=YOUR_APP_SECRET_KEY
|
APP_SECRET=YOUR_APP_SECRET_KEY
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
@@ -13,3 +14,9 @@ DB_NAME=YOUR_DATABASE_NAME
|
|||||||
|
|
||||||
# Frontend URL (for CORS configuration)
|
# Frontend URL (for CORS configuration)
|
||||||
FRONTEND_URL=http://localhost:3000
|
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=
|
||||||
|
|||||||
23
Backend/package-lock.json
generated
23
Backend/package-lock.json
generated
@@ -6,6 +6,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.1",
|
"dotenv": "^16.4.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
@@ -333,6 +334,28 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.1",
|
"dotenv": "^16.4.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
|||||||
@@ -1,86 +1,32 @@
|
|||||||
// Load the express module to create a web application
|
|
||||||
|
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
|
const cors = require("cors");
|
||||||
|
const cookieParser = require("cookie-parser");
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
// Configure it
|
// Trust reverse proxy (Apache on VPS)
|
||||||
|
app.set("trust proxy", 1);
|
||||||
/* ************************************************************************* */
|
|
||||||
|
|
||||||
// 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");
|
|
||||||
|
|
||||||
|
// CORS — frontend + SuperOAuth origins
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
origin: [
|
origin: [
|
||||||
process.env.FRONTEND_URL,
|
process.env.FRONTEND_URL,
|
||||||
process.env.SUPER_OAUTH_URL,
|
process.env.SUPER_OAUTH_URL,
|
||||||
],
|
].filter(Boolean),
|
||||||
credentials: true,
|
credentials: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
/* ************************************************************************* */
|
// Cookie parser with signing
|
||||||
|
const cookieSecret = process.env.COOKIE_SECRET;
|
||||||
// Request Parsing: Understanding the purpose of this part
|
if (!cookieSecret) {
|
||||||
|
console.error("COOKIE_SECRET manquant — cookies non signés !");
|
||||||
// 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.
|
app.use(cookieParser(cookieSecret));
|
||||||
// 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:
|
|
||||||
|
|
||||||
|
// JSON body parser
|
||||||
app.use(express.json());
|
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
|
// Import the API routes from the router module
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
@@ -89,35 +35,7 @@ const router = require("./router");
|
|||||||
// Mount the API routes under the "/api" endpoint
|
// Mount the API routes under the "/api" endpoint
|
||||||
app.use("/api", router);
|
app.use("/api", router);
|
||||||
|
|
||||||
/* ************************************************************************* */
|
// Serve frontend static files in production
|
||||||
|
|
||||||
// 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`);
|
|
||||||
// });
|
|
||||||
|
|
||||||
app.use("*", (req, res) => {
|
app.use("*", (req, res) => {
|
||||||
if (req.originalUrl.includes("assets")) {
|
if (req.originalUrl.includes("assets")) {
|
||||||
res.sendFile(
|
res.sendFile(
|
||||||
@@ -127,26 +45,5 @@ app.use("*", (req, res) => {
|
|||||||
res.sendFile(path.resolve(__dirname, `../../frontend/dist/index.html`));
|
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;
|
module.exports = app;
|
||||||
|
|||||||
@@ -1,78 +1,175 @@
|
|||||||
const jwt = require("jsonwebtoken");
|
|
||||||
const tables = require("../tables");
|
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";
|
||||||
|
|
||||||
/**
|
function cookieOptions(maxAgeDays) {
|
||||||
* GET /api/auth/callback?code=<token>
|
const isProduction = process.env.NODE_ENV === "production";
|
||||||
*
|
return {
|
||||||
* Reçoit le token SuperOAuth depuis le frontend après redirect OAuth.
|
httpOnly: true,
|
||||||
* Valide auprès de SuperOAuth, résout ou crée le user local, retourne un JWT local.
|
signed: true,
|
||||||
*/
|
secure: isProduction,
|
||||||
const callback = async (req, res) => {
|
sameSite: "lax",
|
||||||
const { code } = req.query;
|
maxAge: maxAgeDays * 24 * 60 * 60 * 1000,
|
||||||
|
};
|
||||||
if (!code) {
|
|
||||||
return res.status(400).json({ message: "Missing OAuth code." });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const superOAuthUrl = process.env.SUPER_OAUTH_URL;
|
async function introspectToken(token) {
|
||||||
if (!superOAuthUrl) {
|
if (!SUPER_OAUTH_URL) {
|
||||||
return res.status(500).json({ message: "Auth service not configured." });
|
throw new Error("SUPER_OAUTH_URL not configured");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const response = await fetch(`${SUPER_OAUTH_URL}/api/v1/auth/token/validate`, {
|
||||||
const response = await fetch(`${superOAuthUrl}/api/v1/auth/token/validate`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ token: code }),
|
body: JSON.stringify({ token }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (!response.ok || !data.data?.valid || !data.data?.user) {
|
if (!response.ok || !data.data?.valid || !data.data?.user) {
|
||||||
return res.status(401).json({ message: "Invalid OAuth token." });
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.data.user.isActive) {
|
if (!data.data.user.isActive) {
|
||||||
return res.status(401).json({ message: "Account is disabled." });
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id: superOAuthId, email, nickname } = data.data.user;
|
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 oauthUser = await introspectToken(token);
|
||||||
|
if (!oauthUser) {
|
||||||
|
return res.status(401).json({ message: "Invalid OAuth token." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: superOAuthId, email, nickname } = oauthUser;
|
||||||
|
|
||||||
let localUser = await tables.users.getBySuperOAuthId(superOAuthId);
|
let localUser = await tables.users.getBySuperOAuthId(superOAuthId);
|
||||||
|
|
||||||
if (!localUser) {
|
if (!localUser) {
|
||||||
// Premier login OAuth — créer le compte local
|
|
||||||
const insertId = await tables.users.create({
|
const insertId = await tables.users.create({
|
||||||
nickname: nickname ?? email?.split("@")[0] ?? `user_${Date.now()}`,
|
nickname: nickname ?? email?.split("@")[0] ?? `user_${Date.now()}`,
|
||||||
mail: email ?? `${superOAuthId}@oauth.local`,
|
mail: email ?? `${superOAuthId}@oauth.local`,
|
||||||
password: "", // pas de password local — auth via SuperOAuth uniquement
|
password: "",
|
||||||
tetardcoin: 1000,
|
tetardcoin: 1000,
|
||||||
});
|
});
|
||||||
await tables.users.linkSuperOAuth(insertId, superOAuthId);
|
await tables.users.linkSuperOAuth(insertId, superOAuthId);
|
||||||
localUser = await tables.users.read(insertId);
|
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({
|
// Set refresh token cookie if provided
|
||||||
message: "Connexion réussie",
|
if (refreshToken) {
|
||||||
user: localUser,
|
res.cookie(REFRESH_COOKIE_NAME, refreshToken, cookieOptions(30));
|
||||||
token,
|
}
|
||||||
});
|
|
||||||
|
return res.status(200).json({ user: localUser });
|
||||||
} catch (err) {
|
} 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." });
|
return res.status(500).json({ message: "Internal server error." });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/auth/logout
|
* POST /api/auth/logout
|
||||||
* Stateless — le token JWT est géré côté client.
|
* Clears session cookies.
|
||||||
*/
|
*/
|
||||||
const logout = (_req, res) => {
|
const logout = (_req, res) => {
|
||||||
|
res.clearCookie(COOKIE_NAME);
|
||||||
|
res.clearCookie(REFRESH_COOKIE_NAME);
|
||||||
return res.status(200).json({ message: "Déconnexion réussie." });
|
return res.status(200).json({ message: "Déconnexion réussie." });
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { callback, logout };
|
module.exports = { session, refresh, me, logout };
|
||||||
|
|||||||
@@ -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) => {
|
if (!userId) {
|
||||||
const token = req.header("x-auth-token");
|
return res.status(401).json({ message: "Access denied. No session." });
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return res
|
|
||||||
.status(401)
|
|
||||||
.json({ message: "Access denied. No token provided." });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, secretKey);
|
const user = await tables.users.read(parseInt(userId, 10));
|
||||||
req.user = decoded.user;
|
if (!user) {
|
||||||
next();
|
return res.status(401).json({ message: "User not found." });
|
||||||
return null;
|
}
|
||||||
} catch (error) {
|
req.user = user.id;
|
||||||
return res.status(401).json({ message: "Invalid token." });
|
return next();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("verifyToken — error", err);
|
||||||
|
return res.status(500).json({ message: "Internal server error." });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,13 @@ const express = require("express");
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
/* ************************************************************************* */
|
|
||||||
// Define Your API Routes Here
|
|
||||||
/* ************************************************************************* */
|
|
||||||
|
|
||||||
// Import Controllers
|
// Import Controllers
|
||||||
const userControllers = require("./controllers/userControllers");
|
const userControllers = require("./controllers/userControllers");
|
||||||
const authControllers = require("./controllers/authControllers");
|
const authControllers = require("./controllers/authControllers");
|
||||||
const saveControllers = require("./controllers/saveControllers");
|
const saveControllers = require("./controllers/saveControllers");
|
||||||
const verifyToken = require("./middlewares/verifyToken");
|
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) => {
|
const verifySelf = (req, res, next) => {
|
||||||
if (String(req.user) !== String(req.params.id)) {
|
if (String(req.user) !== String(req.params.id)) {
|
||||||
return res.status(403).json({ message: "Forbidden." });
|
return res.status(403).json({ message: "Forbidden." });
|
||||||
@@ -21,11 +16,13 @@ const verifySelf = (req, res, next) => {
|
|||||||
return next();
|
return next();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auth SuperOAuth
|
// Auth — PKCE flow (cookie-based)
|
||||||
router.get("/auth/callback", authControllers.callback);
|
router.post("/auth/session", authControllers.session);
|
||||||
|
router.post("/auth/refresh", authControllers.refresh);
|
||||||
|
router.get("/auth/me", authControllers.me);
|
||||||
router.post("/auth/logout", authControllers.logout);
|
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", verifyToken, userControllers.browse);
|
||||||
router.get("/users/:id", verifyToken, verifySelf, userControllers.read);
|
router.get("/users/:id", verifyToken, verifySelf, userControllers.read);
|
||||||
router.get("/users/:id/field", 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.delete("/users/:id", verifyToken, verifySelf, userControllers.destroy);
|
||||||
router.post("/login", userControllers.login);
|
router.post("/login", userControllers.login);
|
||||||
|
|
||||||
// Sync game state — SuperOAuth uniquement
|
// Sync game state — cookie auth (was verifyOAuth, now same as verifyToken)
|
||||||
router.patch("/users/:id/coins", verifyOAuth, verifySelf, userControllers.updateCoins);
|
router.patch("/users/:id/coins", verifyToken, verifySelf, userControllers.updateCoins);
|
||||||
|
|
||||||
// Game saves — JWT required
|
// Game saves — cookie auth
|
||||||
router.get("/save", verifyToken, saveControllers.load);
|
router.get("/save", verifyToken, saveControllers.load);
|
||||||
router.post("/save", verifyToken, saveControllers.save);
|
router.post("/save", verifyToken, saveControllers.save);
|
||||||
|
|
||||||
|
|
||||||
/* ************************************************************************* */
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# .env.sample - Sample Environment Variables for Frontend (Vite)
|
# .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
|
VITE_BACKEND_URL=http://localhost:3310
|
||||||
|
|
||||||
# SuperOAuth URL (OAuth login provider)
|
# SuperOAuth PKCE — OAuth provider
|
||||||
VITE_SUPEROAUTH_URL=https://superoauth.tetardtek.com
|
VITE_OAUTH_URL=https://superoauth.tetardtek.com
|
||||||
|
VITE_OAUTH_CLIENT_ID=clickerz
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import React, {
|
import React, { createContext, useContext, useState, useMemo, useEffect } from "react";
|
||||||
createContext,
|
|
||||||
useContext,
|
|
||||||
useState,
|
|
||||||
useMemo,
|
|
||||||
useEffect,
|
|
||||||
} from "react";
|
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
const decodeJwtPayload = (token) =>
|
import { apiFetch } from "../lib/api";
|
||||||
JSON.parse(atob(token.split(".")[1]));
|
|
||||||
|
|
||||||
const AuthContext = createContext();
|
const AuthContext = createContext();
|
||||||
|
|
||||||
@@ -15,134 +8,54 @@ import React, {
|
|||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
const refresh = async () => {
|
||||||
const fetchData = async () => {
|
|
||||||
const jwtToken = localStorage.getItem("token");
|
|
||||||
|
|
||||||
if (jwtToken) {
|
|
||||||
try {
|
try {
|
||||||
const decodedPayload = decodeJwtPayload(jwtToken);
|
const data = await apiFetch("/auth/me");
|
||||||
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);
|
setUser(data);
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error("Error fetching user data:", error);
|
setUser(null);
|
||||||
localStorage.removeItem("token");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchData();
|
useEffect(() => {
|
||||||
|
refresh().finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loginWithOAuth = async (token) => {
|
useEffect(() => {
|
||||||
const res = await fetch(
|
const onExpired = () => setUser(null);
|
||||||
`${import.meta.env.VITE_BACKEND_URL}/api/auth/callback?code=${encodeURIComponent(token)}`
|
window.addEventListener("auth:expired", onExpired);
|
||||||
);
|
return () => window.removeEventListener("auth:expired", onExpired);
|
||||||
const data = await res.json();
|
}, []);
|
||||||
|
|
||||||
if (!res.ok) {
|
const logout = async () => {
|
||||||
throw new Error(data.message || "OAuth login failed");
|
try {
|
||||||
|
await apiFetch("/auth/logout", { method: "POST" });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem("token", data.token);
|
|
||||||
setUser(data.user);
|
|
||||||
return data.user;
|
|
||||||
};
|
|
||||||
|
|
||||||
const logout = () => {
|
|
||||||
localStorage.removeItem("token");
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const editUser = async (updatedFields) => {
|
const editUser = async (updatedFields) => {
|
||||||
try {
|
const data = await apiFetch(`/users/${user.id}`, {
|
||||||
const response = await fetch(
|
|
||||||
`${import.meta.env.VITE_BACKEND_URL}/api/users/${user.id}`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-auth-token": localStorage.getItem("token"),
|
|
||||||
},
|
|
||||||
body: JSON.stringify(updatedFields),
|
body: JSON.stringify(updatedFields),
|
||||||
}
|
});
|
||||||
);
|
setUser((prev) => ({ ...prev, ...data.user }));
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const updatedUser = await response.json();
|
|
||||||
setUser((prevUser) => ({
|
|
||||||
...prevUser,
|
|
||||||
...updatedUser.user,
|
|
||||||
}));
|
|
||||||
return "User updated successfully";
|
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) => {
|
const authContextValue = useMemo(
|
||||||
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,
|
user,
|
||||||
loading,
|
loading,
|
||||||
logout,
|
logout,
|
||||||
loginWithOAuth,
|
refresh,
|
||||||
editUser,
|
editUser,
|
||||||
sendPasswordResetEmail,
|
setUser: (newUser) => setUser(newUser),
|
||||||
setUser: (newUser) => {
|
}),
|
||||||
setUser(newUser);
|
[user, loading]
|
||||||
},
|
);
|
||||||
};
|
|
||||||
}, [user, loading, logout]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={authContextValue}>
|
<AuthContext.Provider value={authContextValue}>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// useSaveSync.ts — Auto-save game state to backend every 30s
|
// useSaveSync.ts — Auto-save game state to backend every 30s
|
||||||
// Requires JWT token in localStorage (set by auth flow)
|
// Cookie-based auth — credentials sent automatically
|
||||||
// Falls back silently if no token (guest mode)
|
|
||||||
|
|
||||||
import { useEffect, useRef, useCallback } from "react";
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
import type { GameState } from "../core/economy";
|
import type { GameState } from "../core/economy";
|
||||||
|
|
||||||
const SAVE_INTERVAL_MS = 30_000; // 30 seconds
|
const SAVE_INTERVAL_MS = 30_000; // 30 seconds
|
||||||
@@ -15,16 +15,13 @@ interface SaveSyncOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function apiRequest(path: string, options: RequestInit = {}) {
|
async function apiRequest(path: string, options: RequestInit = {}) {
|
||||||
const token = localStorage.getItem("token");
|
|
||||||
if (!token) return null;
|
|
||||||
|
|
||||||
const res = await fetch(`${BACKEND_URL}/api${path}`, {
|
const res = await fetch(`${BACKEND_URL}/api${path}`, {
|
||||||
...options,
|
credentials: "include",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"x-auth-token": token,
|
|
||||||
...options.headers,
|
...options.headers,
|
||||||
},
|
},
|
||||||
|
...options,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -37,17 +34,15 @@ async function apiRequest(path: string, options: RequestInit = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useSaveSync({ getGameState, onLoad, playTimeSeconds }: SaveSyncOptions) {
|
export function useSaveSync({ getGameState, onLoad, playTimeSeconds }: SaveSyncOptions) {
|
||||||
|
const { user } = useAuth();
|
||||||
const lastSaveRef = useRef<string | null>(null);
|
const lastSaveRef = useRef<string | null>(null);
|
||||||
const loadedRef = useRef(false);
|
const loadedRef = useRef(false);
|
||||||
|
|
||||||
// Load save on mount (once)
|
// Load save on mount (once)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loadedRef.current) return;
|
if (loadedRef.current || !user) return;
|
||||||
loadedRef.current = true;
|
loadedRef.current = true;
|
||||||
|
|
||||||
const token = localStorage.getItem("token");
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
apiRequest("/save").then((data) => {
|
apiRequest("/save").then((data) => {
|
||||||
if (data?.gameState) {
|
if (data?.gameState) {
|
||||||
onLoad(data.gameState);
|
onLoad(data.gameState);
|
||||||
@@ -55,12 +50,11 @@ export function useSaveSync({ getGameState, onLoad, playTimeSeconds }: SaveSyncO
|
|||||||
console.info("[SaveSync] Loaded save from server");
|
console.info("[SaveSync] Loaded save from server");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [onLoad]);
|
}, [onLoad, user]);
|
||||||
|
|
||||||
// Save function
|
// Save function
|
||||||
const saveToServer = useCallback(async () => {
|
const saveToServer = useCallback(async () => {
|
||||||
const token = localStorage.getItem("token");
|
if (!user) return;
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
const gameState = getGameState();
|
const gameState = getGameState();
|
||||||
const result = await apiRequest("/save", {
|
const result = await apiRequest("/save", {
|
||||||
@@ -71,37 +65,31 @@ export function useSaveSync({ getGameState, onLoad, playTimeSeconds }: SaveSyncO
|
|||||||
if (result?.lastSave) {
|
if (result?.lastSave) {
|
||||||
lastSaveRef.current = result.lastSave;
|
lastSaveRef.current = result.lastSave;
|
||||||
}
|
}
|
||||||
}, [getGameState, playTimeSeconds]);
|
}, [getGameState, playTimeSeconds, user]);
|
||||||
|
|
||||||
// Auto-save interval
|
// Auto-save interval
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem("token");
|
if (!user) return undefined;
|
||||||
if (!token) return undefined;
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
saveToServer();
|
saveToServer();
|
||||||
}, SAVE_INTERVAL_MS);
|
}, SAVE_INTERVAL_MS);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [saveToServer]);
|
}, [saveToServer, user]);
|
||||||
|
|
||||||
// Save on page unload
|
// Save on page unload
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleUnload = () => {
|
const handleUnload = () => {
|
||||||
const token = localStorage.getItem("token");
|
if (!user) return;
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
const gameState = getGameState();
|
const gameState = getGameState();
|
||||||
const payload = JSON.stringify({ gameState, playTimeSeconds });
|
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`, {
|
fetch(`${BACKEND_URL}/api/save`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
credentials: "include",
|
||||||
"Content-Type": "application/json",
|
headers: { "Content-Type": "application/json" },
|
||||||
"x-auth-token": token,
|
|
||||||
},
|
|
||||||
body: payload,
|
body: payload,
|
||||||
keepalive: true,
|
keepalive: true,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
@@ -109,7 +97,7 @@ export function useSaveSync({ getGameState, onLoad, playTimeSeconds }: SaveSyncO
|
|||||||
|
|
||||||
window.addEventListener("beforeunload", handleUnload);
|
window.addEventListener("beforeunload", handleUnload);
|
||||||
return () => window.removeEventListener("beforeunload", handleUnload);
|
return () => window.removeEventListener("beforeunload", handleUnload);
|
||||||
}, [getGameState, playTimeSeconds]);
|
}, [getGameState, playTimeSeconds, user]);
|
||||||
|
|
||||||
return { saveToServer, lastSave: lastSaveRef.current };
|
return { saveToServer, lastSave: lastSaveRef.current };
|
||||||
}
|
}
|
||||||
|
|||||||
56
Frontend/src/lib/api.js
Normal file
56
Frontend/src/lib/api.js
Normal file
@@ -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();
|
||||||
|
}
|
||||||
83
Frontend/src/lib/oauth.js
Normal file
83
Frontend/src/lib/oauth.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,26 +1,60 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { useNavigate, useSearchParams, Link } from "react-router-dom";
|
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 { useAuth } from "../context/AuthContext";
|
||||||
import "../scss/pages.scss";
|
import "../scss/pages.scss";
|
||||||
|
|
||||||
export default function AuthCallback() {
|
export default function AuthCallback() {
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const { loginWithOAuth } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { refresh } = useAuth();
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
const called = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = searchParams.get("token");
|
if (called.current) return;
|
||||||
|
called.current = true;
|
||||||
|
|
||||||
if (!token) {
|
const params = new URLSearchParams(window.location.search);
|
||||||
setError("Token manquant dans l'URL.");
|
const code = params.get("code");
|
||||||
|
const err = params.get("error");
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
setError(err);
|
||||||
return;
|
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 }))
|
.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) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { buildAuthUrl, saveVerifier } from "../lib/oauth";
|
||||||
import "../scss/pages.scss";
|
import "../scss/pages.scss";
|
||||||
|
|
||||||
const SUPEROAUTH_URL = import.meta.env.VITE_SUPEROAUTH_URL;
|
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -13,9 +12,11 @@ export default function Login() {
|
|||||||
if (user) navigate("/", { replace: true });
|
if (user) navigate("/", { replace: true });
|
||||||
}, [user, navigate]);
|
}, [user, navigate]);
|
||||||
|
|
||||||
const handleLogin = () => {
|
const handleLogin = async (provider = "discord") => {
|
||||||
const callbackUrl = `${window.location.origin}/callback`;
|
const redirectUri = `${window.location.origin}/callback`;
|
||||||
window.location.href = `${SUPEROAUTH_URL}/api/v1/oauth/discord?redirectUrl=${encodeURIComponent(callbackUrl)}&tenantId=clickerz`;
|
const { url, verifier } = await buildAuthUrl(redirectUri, provider);
|
||||||
|
saveVerifier(verifier);
|
||||||
|
window.location.href = url;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -23,8 +24,8 @@ export default function Login() {
|
|||||||
<div className="containererror">
|
<div className="containererror">
|
||||||
<h1>Connexion</h1>
|
<h1>Connexion</h1>
|
||||||
<p className="message">Connecte-toi pour sauvegarder ta progression.</p>
|
<p className="message">Connecte-toi pour sauvegarder ta progression.</p>
|
||||||
<button className="btn-return" onClick={handleLogin} type="button">
|
<button className="btn-return" onClick={() => handleLogin("discord")} type="button">
|
||||||
Se connecter avec SuperOAuth
|
Se connecter avec Discord
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user