feat: PKCE auth + CI/CD deploy
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:
2026-03-24 13:01:15 +01:00
parent 39f683a31e
commit 91d1616dd7
15 changed files with 548 additions and 393 deletions

56
Frontend/src/lib/api.js Normal file
View 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
View 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);
}