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). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
54 lines
2.2 KiB
TypeScript
54 lines
2.2 KiB
TypeScript
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,
|
|
limiter: LoginRateLimiter,
|
|
serverVersion: string,
|
|
): void {
|
|
app.post('/api/v1/auth/login', { config: { public: true } }, async (req, reply) => {
|
|
const wait = limiter.check();
|
|
if (wait !== null) {
|
|
return reply.status(429).send({ error: { code: 'RATE_LIMITED', message: `Retry in ${Math.ceil(wait / 1000)}s` } });
|
|
}
|
|
const body = req.body as Partial<LoginRequest> | null;
|
|
const ctx = typeof body?.token === 'string' ? auth.verifyRawToken(body.token) : null;
|
|
if (!ctx) {
|
|
limiter.recordFailure();
|
|
return reply.status(401).send({ error: { code: 'BAD_TOKEN', message: 'Invalid token' } });
|
|
}
|
|
limiter.recordSuccess();
|
|
void reply.setCookie(auth.cookieName, auth.issueCookie(ctx), {
|
|
path: '/',
|
|
httpOnly: true,
|
|
sameSite: 'strict',
|
|
secure: isHttpsRequest(req),
|
|
maxAge: 30 * 24 * 3600,
|
|
});
|
|
const res: LoginResponse = { ok: true, label: ctx.label };
|
|
return reply.send(res);
|
|
});
|
|
|
|
app.get('/api/v1/auth/me', async (req, reply) => {
|
|
const res: MeResponse = { ok: true, tokenLabel: req.authContext?.label ?? 'unknown', serverVersion };
|
|
return reply.send(res);
|
|
});
|
|
|
|
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 });
|
|
});
|
|
}
|