From 1d394be316d5edec12c32ff0346d90c57289cbba Mon Sep 17 00:00:00 2001 From: "Pascal S." Date: Thu, 25 Jun 2026 14:50:54 +0200 Subject: [PATCH] fix: warm up freshly created session before writing .env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une session OAuth2 ENT n'est pleinement active qu'après une première requête authentifiée. loginAndGetCookies capturait les cookies du 302 sans finaliser la session, donc au 1er essai les tokens écrits dans le .env pointaient vers une session pas encore initialisée (rejetés), puis acceptés au 2e essai. Surtout visible sur reconnect-last/historique (skipConfirm supprime toute pause entre login et écriture). warmSession sonde /auth/oauth2/userinfo (réutilise isSessionAlive) jusqu'à ce que la session réponde, avant d'écrire les .env. Best-effort : on écrit quand même si le réseau échoue ou si le budget de sondes est épuisé. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/services/EnvSyncService.ts | 35 ++++++++++++++++++++++++++ tests/services/EnvSyncService.test.ts | 36 +++++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/services/EnvSyncService.ts b/src/services/EnvSyncService.ts index 42c62e7..e0be1bd 100644 --- a/src/services/EnvSyncService.ts +++ b/src/services/EnvSyncService.ts @@ -35,6 +35,15 @@ const STALE_AFTER_MS = 8 * 60 * 60 * 1000; const KEEPALIVE_INTERVAL_MS = 2 * 60 * 1000; /** Intervalle minimum accepté (anti-boucle serrée). */ const KEEPALIVE_MIN_INTERVAL_MS = 30 * 1000; +/** + * Nombre de sondes pour « réveiller » une session fraîchement créée avant d'écrire les .env. + * Une session OAuth2 ENT n'est pleinement active qu'après une première requête authentifiée : + * sans ça, les cookies écrits pointent vers une session pas encore initialisée et sont rejetés + * au 1er essai (puis acceptés au 2e, l'utilisateur étant devenu « chaud »). Cf. bug reconnect-last. + */ +const SESSION_WARMUP_ATTEMPTS = 5; +/** Délai entre deux sondes de réveil (ms). */ +const SESSION_WARMUP_DELAY_MS = 400; export interface ConnectOptions { env?: string; @@ -95,6 +104,11 @@ export function reconnectOptionsFromLast(last: LastConnection): ConnectOptions { }; } +/** Sommeil simple (non annulable). */ +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + /** Sommeil annulable via un AbortSignal. Retourne 'aborted' si interrompu. */ function sleep(ms: number, signal: AbortSignal): Promise<'done' | 'aborted'> { return new Promise((resolve) => { @@ -200,6 +214,9 @@ export class EnvSyncService { password: creds.password, }); + // Réveille la session côté serveur avant d'écrire les .env (cf. SESSION_WARMUP_ATTEMPTS). + await this.warmSession(env, cookies.sessionId); + await recordConnection( env.id, creds.login, @@ -224,6 +241,24 @@ export class EnvSyncService { return cookies; } + /** + * « Réveille » la session fraîchement créée : sonde `/auth/oauth2/userinfo` jusqu'à une + * réponse vivante (la sonde réalise la première requête authentifiée qui finalise la session + * côté serveur). Best-effort : si la sonde échoue (réseau) ou n'aboutit pas dans le budget de + * tentatives, on poursuit quand même l'écriture des .env plutôt que d'échouer la connexion. + */ + private async warmSession(env: EnvironmentConfig, sessionId: string): Promise { + for (let attempt = 1; attempt <= SESSION_WARMUP_ATTEMPTS; attempt++) { + try { + if (await this.authClient.isSessionAlive(env.url, sessionId)) return; + } catch { + // Réseau indisponible : inutile d'insister, on tentera l'écriture des .env. + return; + } + if (attempt < SESSION_WARMUP_ATTEMPTS) await delay(SESSION_WARMUP_DELAY_MS); + } + } + /** * Boucle keep-alive (premier plan) : ping régulier de la session pour la maintenir active * (timeout d'inactivité glissant). Tant que la session répond, on ne touche à rien — donc diff --git a/tests/services/EnvSyncService.test.ts b/tests/services/EnvSyncService.test.ts index e74c43d..d2a4a6a 100644 --- a/tests/services/EnvSyncService.test.ts +++ b/tests/services/EnvSyncService.test.ts @@ -20,17 +20,31 @@ import { AuthError } from '../../src/utils/errors.js'; /** Faux client d'auth : enregistre les arguments reçus et renvoie des cookies fixes. */ class FakeAuthClient implements IAuthClient { calls: Array<{ envUrl: string; credentials: AuthCredentials }> = []; + /** Sessions sondées par le réveil (warmSession), dans l'ordre. */ + aliveCalls: Array<{ envUrl: string; sessionId: string }> = []; + /** + * File de réponses pour isSessionAlive : chaque entrée est consommée par appel. + * Une fois la file vide, renvoie true (session réveillée). + */ + private aliveQueue: boolean[] = []; constructor(private readonly result: AuthCookies | Error) {} + /** Programme les réponses successives de isSessionAlive (ex. [false, false, true]). */ + withAliveSequence(seq: boolean[]): this { + this.aliveQueue = [...seq]; + return this; + } + async loginAndGetCookies(envUrl: string, credentials: AuthCredentials): Promise { this.calls.push({ envUrl, credentials }); if (this.result instanceof Error) throw this.result; return this.result; } - async isSessionAlive(): Promise { - return true; + async isSessionAlive(envUrl: string, sessionId: string): Promise { + this.aliveCalls.push({ envUrl, sessionId }); + return this.aliveQueue.shift() ?? true; } } @@ -108,6 +122,24 @@ describe.sequential('EnvSyncService.runInteractive', () => { expect(typeof recents[0].connectedAt).toBe('number'); }); + it('réveille la session (sonde) avant d’écrire le .env, et sonde la bonne session', async () => { + const client = new FakeAuthClient({ xsrfToken: 'x', sessionId: 'sess-456' }).withAliveSequence([ + false, + true, + ]); + const service = new EnvSyncService(client); + + await service.runInteractive({ env: ENV_ID, login: LOGIN, apps: ['myapp'], skipConfirm: true }); + + // la session a été sondée jusqu'à réponse vivante, avec le sessionId fraîchement obtenu + expect(client.aliveCalls).toHaveLength(2); + expect(client.aliveCalls.every((c) => c.sessionId === 'sess-456')).toBe(true); + + // et les cookies ont bien été écrits une fois la session active + const env = await readEnvFile(appEnvPath); + expect(env.VITE_ONE_SESSION_ID).toBe('sess-456'); + }); + it("ne touche pas au .env si l'authentification échoue", async () => { const client = new FakeAuthClient(new AuthError('bad credentials')); const service = new EnvSyncService(client);