From 7932659a734b1fa0b1a8efab257e68d4f32c52bb Mon Sep 17 00:00:00 2001 From: Tetardtek Date: Sun, 22 Mar 2026 12:50:07 +0100 Subject: [PATCH] feat(auth): PKCE flow preparation + CallbackPage dual-mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add oauth.ts — PKCE helpers (code verifier/challenge, token exchange) - Add LoginButton — "Se connecter avec SuperOAuth" component - Update CallbackPage — handles both PKCE (?code) and legacy (?token) flows - Update .env.example — VITE_OAUTH_URL + VITE_OAUTH_CLIENT_ID PKCE flow ready for when SuperOAuth exposes /oauth/authorize endpoint. Legacy flow (redirect + token query param) remains active in production. --- frontend/.env.example | 4 + frontend/src/components/auth/LoginButton.tsx | 39 +++++++ frontend/src/lib/oauth.ts | 110 +++++++++++++++++++ frontend/src/pages/CallbackPage.tsx | 51 +++++++-- 4 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/auth/LoginButton.tsx create mode 100644 frontend/src/lib/oauth.ts diff --git a/frontend/.env.example b/frontend/.env.example index 5218608..0f7986d 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -2,3 +2,7 @@ # Le flow : /api/v1/auth/oauth/:provider?redirectUrl= # Valeur : voir brain/MYSECRETS section originsdigital VITE_SUPEROAUTH_URL= + +# SuperOAuth PKCE (Step 3) — flow authorization_code avec PKCE +VITE_OAUTH_URL=https://oauth.tetardtek.com +VITE_OAUTH_CLIENT_ID=originsdigital diff --git a/frontend/src/components/auth/LoginButton.tsx b/frontend/src/components/auth/LoginButton.tsx new file mode 100644 index 0000000..5c5af7d --- /dev/null +++ b/frontend/src/components/auth/LoginButton.tsx @@ -0,0 +1,39 @@ +import { useState } from 'react'; +import { buildAuthUrl, saveVerifier } from '../../lib/oauth'; + +interface LoginButtonProps { + className?: string; +} + +export default function LoginButton({ className }: LoginButtonProps) { + const [loading, setLoading] = useState(false); + + async function handleClick() { + if (loading) return; + setLoading(true); + try { + const redirectUri = `${window.location.origin}/callback`; + const { url, verifier } = await buildAuthUrl(redirectUri); + saveVerifier(verifier); + window.location.href = url; + } catch { + setLoading(false); + } + } + + return ( + + ); +} diff --git a/frontend/src/lib/oauth.ts b/frontend/src/lib/oauth.ts new file mode 100644 index 0000000..61f4d15 --- /dev/null +++ b/frontend/src/lib/oauth.ts @@ -0,0 +1,110 @@ +// OAuth 2.0 PKCE client — SuperOAuth Tier 3 multi-tenant +// Token stocké en sessionStorage (pas localStorage — sécurité, scope onglet) + +const OAUTH_URL = import.meta.env.VITE_OAUTH_URL || ''; +const OAUTH_CLIENT_ID = import.meta.env.VITE_OAUTH_CLIENT_ID || ''; + +const SESSION_KEY_TOKEN = 'od_access_token'; +const SESSION_KEY_VERIFIER = 'od_pkce_verifier'; + +// --- PKCE helpers --- + +function base64UrlEncode(buffer: ArrayBuffer): string { + return btoa(String.fromCharCode(...new Uint8Array(buffer))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +export function generateCodeVerifier(): string { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return base64UrlEncode(array.buffer); +} + +export async function generateCodeChallenge(verifier: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const digest = await crypto.subtle.digest('SHA-256', data); + return base64UrlEncode(digest); +} + +// --- Auth URL --- + +export async function buildAuthUrl( + redirectUri: string, + scope = 'openid profile email', + clientId = OAUTH_CLIENT_ID, +): Promise<{ url: string; verifier: string }> { + 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, + code_challenge: challenge, + code_challenge_method: 'S256', + }); + + return { + url: `${OAUTH_URL}/oauth/authorize?${params.toString()}`, + verifier, + }; +} + +// --- Token exchange --- + +export async function exchangeCode( + code: string, + verifier: string, + redirectUri: string, + clientId = OAUTH_CLIENT_ID, +): Promise { + 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() as { access_token?: string }; + if (!data.access_token) throw new Error('No access_token in OAuth response'); + + sessionStorage.setItem(SESSION_KEY_TOKEN, data.access_token); + return data.access_token; +} + +// --- Token accessors --- + +export function getAccessToken(): string | null { + return sessionStorage.getItem(SESSION_KEY_TOKEN); +} + +export function clearAccessToken(): void { + sessionStorage.removeItem(SESSION_KEY_TOKEN); + sessionStorage.removeItem(SESSION_KEY_VERIFIER); +} + +// --- PKCE verifier persistence (avant redirect) --- + +export function saveVerifier(verifier: string): void { + sessionStorage.setItem(SESSION_KEY_VERIFIER, verifier); +} + +export function loadVerifier(): string | null { + return sessionStorage.getItem(SESSION_KEY_VERIFIER); +} diff --git a/frontend/src/pages/CallbackPage.tsx b/frontend/src/pages/CallbackPage.tsx index 581a601..456de0f 100644 --- a/frontend/src/pages/CallbackPage.tsx +++ b/frontend/src/pages/CallbackPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { apiFetch } from '../lib/api'; +import { exchangeCode, loadVerifier } from '../lib/oauth'; import { useAuthContext } from '../context/AuthContext'; import type { User } from '../context/AuthContext'; @@ -16,22 +17,50 @@ export default function CallbackPage() { useEffect(() => { const params = new URLSearchParams(window.location.search); - const token = params.get('token'); - if (!token) { - navigate('/', { replace: true }); + // --- Erreur OAuth explicite (state=error, error=access_denied, etc.) --- + const oauthError = params.get('error'); + if (oauthError) { + const desc = params.get('error_description') ?? oauthError; + setError(`Erreur OAuth : ${desc}`); return; } - apiFetch('/auth/session', { - method: 'POST', - body: JSON.stringify({ token }), - }) - .then((res) => { - setUser(res.data.user); - navigate('/', { replace: true }); + // --- Flow PKCE : ?code= présent --- + const code = params.get('code'); + if (code) { + const verifier = loadVerifier(); + if (!verifier) { + setError('Session PKCE expirée. Recommence la connexion.'); + return; + } + const redirectUri = `${window.location.origin}/callback`; + + exchangeCode(code, verifier, redirectUri) + .then(() => { + navigate('/app', { replace: true }); + }) + .catch(() => setError("Échec de l'échange de code OAuth. Réessaie.")); + return; + } + + // --- Flow session (Step 2 — token JWT passé en query param) --- + const token = params.get('token'); + if (token) { + apiFetch('/auth/session', { + method: 'POST', + body: JSON.stringify({ token }), }) - .catch(() => setError("Échec de l'authentification. Réessaie.")); + .then((res) => { + setUser(res.data.user); + navigate('/', { replace: true }); + }) + .catch(() => setError("Échec de l'authentification. Réessaie.")); + return; + } + + // Aucun paramètre reconnu → retour accueil + navigate('/', { replace: true }); }, [navigate, setUser]); if (error) {