From d68041e2f11487bc0c9ab7511973650150f07d4d Mon Sep 17 00:00:00 2001 From: Tetardtek Date: Sun, 22 Mar 2026 16:14:55 +0100 Subject: [PATCH] feat(auth): PKCE client refinements + backend refresh token support - oauth.ts: provider param, TokenResponse typing, exchangeCode returns full response - LoginPage: fully async handleOAuth with buildAuthUrl - CallbackPage: dual-mode PKCE (code) + legacy (token), refresh token forwarding - LoginButton: provider prop support - auth.routes: POST /auth/session accepts refreshToken, sets od_refresh cookie --- backend/src/routes/auth.routes.ts | 5 ++++- frontend/src/components/auth/LoginButton.tsx | 5 +++-- frontend/src/lib/oauth.ts | 17 +++++++++++++---- frontend/src/pages/CallbackPage.tsx | 15 +++++++++++++-- frontend/src/pages/LoginPage.tsx | 15 +++++++++++---- 5 files changed, 44 insertions(+), 13 deletions(-) diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index dcd18f2..87aa7f7 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -92,7 +92,7 @@ router.post("/login", async (req: Request, res: Response): Promise => { * le valide, puis le pose en httpOnly cookie. */ router.post("/session", async (req: Request, res: Response): Promise => { - const { token } = req.body as { token?: string }; + const { token, refreshToken } = req.body as { token?: string; refreshToken?: string }; if (!token) { res.status(400).json({ success: false, error: "MISSING_TOKEN" }); @@ -126,6 +126,9 @@ router.post("/session", async (req: Request, res: Response): Promise => { await upsertUser(data.data.user as { id: string; email: string | null; nickname: string }); res.cookie(COOKIE_NAME, token, COOKIE_OPTIONS); + if (refreshToken) { + res.cookie(REFRESH_COOKIE_NAME, refreshToken, REFRESH_COOKIE_OPTIONS); + } res.json({ success: true, data: { user: data.data.user } }); } catch (err) { logger.error("POST /auth/session — auth service unavailable", { err }); diff --git a/frontend/src/components/auth/LoginButton.tsx b/frontend/src/components/auth/LoginButton.tsx index 5c5af7d..fe4561d 100644 --- a/frontend/src/components/auth/LoginButton.tsx +++ b/frontend/src/components/auth/LoginButton.tsx @@ -3,9 +3,10 @@ import { buildAuthUrl, saveVerifier } from '../../lib/oauth'; interface LoginButtonProps { className?: string; + provider?: string; } -export default function LoginButton({ className }: LoginButtonProps) { +export default function LoginButton({ className, provider = 'discord' }: LoginButtonProps) { const [loading, setLoading] = useState(false); async function handleClick() { @@ -13,7 +14,7 @@ export default function LoginButton({ className }: LoginButtonProps) { setLoading(true); try { const redirectUri = `${window.location.origin}/callback`; - const { url, verifier } = await buildAuthUrl(redirectUri); + const { url, verifier } = await buildAuthUrl(redirectUri, provider); saveVerifier(verifier); window.location.href = url; } catch { diff --git a/frontend/src/lib/oauth.ts b/frontend/src/lib/oauth.ts index 61f4d15..11f5fa0 100644 --- a/frontend/src/lib/oauth.ts +++ b/frontend/src/lib/oauth.ts @@ -33,6 +33,7 @@ export async function generateCodeChallenge(verifier: string): Promise { export async function buildAuthUrl( redirectUri: string, + provider: string, scope = 'openid profile email', clientId = OAUTH_CLIENT_ID, ): Promise<{ url: string; verifier: string }> { @@ -46,6 +47,7 @@ export async function buildAuthUrl( redirect_uri: redirectUri, scope, state, + provider, code_challenge: challenge, code_challenge_method: 'S256', }); @@ -58,12 +60,20 @@ export async function buildAuthUrl( // --- Token exchange --- +export interface TokenResponse { + access_token: string; + refresh_token?: string; + token_type: string; + expires_in: number; + scope?: string; +} + export async function exchangeCode( code: string, verifier: string, redirectUri: string, clientId = OAUTH_CLIENT_ID, -): Promise { +): Promise { const response = await fetch(`${OAUTH_URL}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, @@ -81,11 +91,10 @@ export async function exchangeCode( throw new Error(`OAuth token exchange failed (${response.status}): ${text}`); } - const data = await response.json() as { access_token?: string }; + const data = await response.json() as TokenResponse; 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; + return data; } // --- Token accessors --- diff --git a/frontend/src/pages/CallbackPage.tsx b/frontend/src/pages/CallbackPage.tsx index 456de0f..e95c69c 100644 --- a/frontend/src/pages/CallbackPage.tsx +++ b/frontend/src/pages/CallbackPage.tsx @@ -37,8 +37,19 @@ export default function CallbackPage() { const redirectUri = `${window.location.origin}/callback`; exchangeCode(code, verifier, redirectUri) - .then(() => { - navigate('/app', { replace: true }); + .then((tokens) => { + // Pass tokens to backend to set httpOnly cookies + sync user + return apiFetch('/auth/session', { + method: 'POST', + body: JSON.stringify({ + token: tokens.access_token, + refreshToken: tokens.refresh_token, + }), + }); + }) + .then((res) => { + setUser(res.data.user); + navigate('/', { replace: true }); }) .catch(() => setError("Échec de l'échange de code OAuth. Réessaie.")); return; diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 73cd7dc..7202099 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { Link, useNavigate, useLocation } from 'react-router-dom'; import { apiFetch } from '../lib/api'; import { useAuthContext, type User } from '../context/AuthContext'; +import { buildAuthUrl, saveVerifier } from '../lib/oauth'; const PROVIDERS = [ { id: 'discord', label: 'Discord' }, @@ -15,8 +16,6 @@ export default function LoginPage() { const location = useLocation(); const { setUser } = useAuthContext(); const from = (location.state as { from?: Location })?.from?.pathname ?? '/'; - const base = import.meta.env.VITE_SUPEROAUTH_URL; - const redirectUrl = encodeURIComponent(window.location.origin + '/callback'); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -24,10 +23,18 @@ export default function LoginPage() { const [error, setError] = useState(null); const [oauthLoading, setOauthLoading] = useState(null); - function handleOAuth(providerId: string) { + async function handleOAuth(providerId: string) { if (oauthLoading) return; setOauthLoading(providerId); - window.location.href = `${base}/api/v1/oauth/${providerId}?redirectUrl=${redirectUrl}&tenantId=origins`; + try { + const redirectUri = `${window.location.origin}/callback`; + const { url, verifier } = await buildAuthUrl(redirectUri, providerId); + saveVerifier(verifier); + window.location.href = url; + } catch { + setOauthLoading(null); + setError('Impossible de démarrer la connexion OAuth.'); + } } async function handleSubmit(e: React.FormEvent) {