feat: cookie Secure conditionnel + sous-commande d'installation de service

Cookie: routes/auth.ts pose `secure` sur le cookie de session quand la requête
arrive en HTTPS (x-forwarded-proto), sans trustProxy — durcit le cookie derrière
Tailscale Serve sans casser le localhost http.

Install: nouveau cli/install.ts + routeur de sous-commandes dans index.ts
(install/uninstall/status/serve). Service utilisateur systemd (Linux) ou launchd
(macOS), bootstrap du token, --dry-run/--no-enable. Rétrocompat stricte du daemon
par défaut (runDaemon extrait).

Tests: app.e2e (cookie Secure local vs HTTPS) + cli-install (fonctions pures).
203/203 verts, acceptation P1/P4 vertes.

Docs: README.md + README.fr.md (installeur multi-OS, distinction utiliser/cloner,
modèle de sécurité durci).
This commit is contained in:
2026-06-17 16:17:08 +02:00
parent 11c45ff060
commit f446a3dda1
7 changed files with 694 additions and 21 deletions

View File

@@ -1,7 +1,16 @@
import type { FastifyInstance } from 'fastify';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import type { LoginRequest, LoginResponse, MeResponse } from '@arboretum/shared';
import type { AuthService, LoginRateLimiter } from '../auth/service.js';
// Tailscale Serve / un reverse-proxy TLS posent x-forwarded-proto. On ne sert jamais
// en TLS direct : `secure` n'est posé que derrière un front HTTPS, jamais en localhost http
// (sinon le navigateur refuserait le cookie sur http://127.0.0.1 et le login local casserait).
function isHttpsRequest(req: FastifyRequest): boolean {
const xfp = req.headers['x-forwarded-proto'];
const proto = (Array.isArray(xfp) ? xfp[0] : xfp)?.split(',')[0]?.trim();
return proto === 'https';
}
export function registerAuthRoutes(
app: FastifyInstance,
auth: AuthService,
@@ -24,6 +33,7 @@ export function registerAuthRoutes(
path: '/',
httpOnly: true,
sameSite: 'strict',
secure: isHttpsRequest(req),
maxAge: 30 * 24 * 3600,
});
const res: LoginResponse = { ok: true, label: ctx.label };
@@ -35,8 +45,9 @@ export function registerAuthRoutes(
return reply.send(res);
});
app.post('/api/v1/auth/logout', async (_req, reply) => {
void reply.clearCookie(auth.cookieName, { path: '/' });
app.post('/api/v1/auth/logout', async (req, reply) => {
// Les attributs doivent matcher ceux posés au login pour que le navigateur efface bien le cookie.
void reply.clearCookie(auth.cookieName, { path: '/', secure: isHttpsRequest(req) });
return reply.send({ ok: true });
});
}