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:
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<AuthContext.Provider value={authContextValue}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<AuthContext.Provider value={authContextValue}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
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 };
|
||||
|
||||
@@ -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<string | null>(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 };
|
||||
}
|
||||
|
||||
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 { 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 (
|
||||
|
||||
@@ -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() {
|
||||
<div className="containererror">
|
||||
<h1>Connexion</h1>
|
||||
<p className="message">Connecte-toi pour sauvegarder ta progression.</p>
|
||||
<button className="btn-return" onClick={handleLogin} type="button">
|
||||
Se connecter avec SuperOAuth
|
||||
<button className="btn-return" onClick={() => handleLogin("discord")} type="button">
|
||||
Se connecter avec Discord
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user