feat(auth): PKCE flow preparation + CallbackPage dual-mode
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 29s

- 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.
This commit is contained in:
2026-03-22 12:50:07 +01:00
parent 32b9af7b02
commit 7932659a73
4 changed files with 193 additions and 11 deletions

View File

@@ -2,3 +2,7 @@
# Le flow : /api/v1/auth/oauth/:provider?redirectUrl=<callback_url> # Le flow : /api/v1/auth/oauth/:provider?redirectUrl=<callback_url>
# Valeur : voir brain/MYSECRETS section originsdigital # Valeur : voir brain/MYSECRETS section originsdigital
VITE_SUPEROAUTH_URL= VITE_SUPEROAUTH_URL=
# SuperOAuth PKCE (Step 3) — flow authorization_code avec PKCE
VITE_OAUTH_URL=https://oauth.tetardtek.com
VITE_OAUTH_CLIENT_ID=originsdigital

View File

@@ -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 (
<button
onClick={handleClick}
disabled={loading}
className={[
'rounded border border-[#c9a84c] px-4 py-2 font-mono text-xs text-[#c9a84c]',
'transition-all duration-150',
'hover:bg-[#c9a84c] hover:text-od-bg',
'disabled:opacity-40 disabled:cursor-not-allowed',
className ?? '',
].join(' ')}
>
{loading ? '…' : 'Se connecter avec SuperOAuth'}
</button>
);
}

110
frontend/src/lib/oauth.ts Normal file
View File

@@ -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<string> {
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<string> {
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);
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { apiFetch } from '../lib/api'; import { apiFetch } from '../lib/api';
import { exchangeCode, loadVerifier } from '../lib/oauth';
import { useAuthContext } from '../context/AuthContext'; import { useAuthContext } from '../context/AuthContext';
import type { User } from '../context/AuthContext'; import type { User } from '../context/AuthContext';
@@ -16,22 +17,50 @@ export default function CallbackPage() {
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const token = params.get('token');
if (!token) { // --- Erreur OAuth explicite (state=error, error=access_denied, etc.) ---
navigate('/', { replace: true }); const oauthError = params.get('error');
if (oauthError) {
const desc = params.get('error_description') ?? oauthError;
setError(`Erreur OAuth : ${desc}`);
return; return;
} }
apiFetch<SessionResponse>('/auth/session', { // --- Flow PKCE : ?code= présent ---
method: 'POST', const code = params.get('code');
body: JSON.stringify({ token }), if (code) {
}) const verifier = loadVerifier();
.then((res) => { if (!verifier) {
setUser(res.data.user); setError('Session PKCE expirée. Recommence la connexion.');
navigate('/', { replace: true }); 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<SessionResponse>('/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]); }, [navigate, setUser]);
if (error) { if (error) {