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
This commit is contained in:
2026-03-22 16:14:55 +01:00
parent 7932659a73
commit d68041e2f1
5 changed files with 44 additions and 13 deletions

View File

@@ -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 {

View File

@@ -33,6 +33,7 @@ export async function generateCodeChallenge(verifier: string): Promise<string> {
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<string> {
): Promise<TokenResponse> {
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 ---

View File

@@ -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<SessionResponse>('/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;

View File

@@ -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<string | null>(null);
const [oauthLoading, setOauthLoading] = useState<string | null>(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) {