batchCooking/apps/api/src/middlewares/require-auth.ts
Nicolas 1d8c001035
Some checks failed
CI / lint (push) Successful in 2m6s
CI / build (push) Successful in 4m0s
CI / intent-service-test (push) Successful in 6m47s
CI / test (push) Failing after 8m58s
CI / e2e (push) Successful in 9m28s
feat(auth): accepte un jeton Bearer en repli du cookie de session
Phase 1 du portage mobile (#39) : purement additif côté navigateur, qui
continue d'utiliser le cookie `session` httpOnly inchangé.

- `POST /auth/login` et `POST /auth/signup` renvoient aussi le JWT dans le
  corps de la réponse (`AuthResponse` = `SafeUserProfile` + `token`), en
  plus de poser le cookie. Un client dont l'origine ne peut pas recevoir
  un cookie `SameSite=Lax` (WebView Capacitor) le stocke et le renvoie.
- `requireAuth` lit le cookie en priorité, puis se rabat sur un en-tête
  `Authorization: Bearer <jwt>` quand le cookie est absent. Les deux
  mécanismes coexistent, aucun n'est retiré.
- `apps/web` : `ApiClient` gagne une abstraction `TokenStore`
  (`get`/`set`/`clear`) ; l'implémentation par défaut est un no-op
  navigateur (le cookie fait tout). `_request` ajoute l'en-tête
  `Authorization` quand un jeton existe ; `login`/`signup` le stockent,
  `logout`/`deleteAccount` l'effacent. `setTokenStore` est le point
  d'injection de la version Capacitor (`@capacitor/preferences`), branchée
  en phase 2 quand la dépendance sera ajoutée.

Tests Mocha : `requireAuth` authentifie via l'en-tête `Authorization` sans
cookie, rejette un jeton Bearer invalide ou un schéma non-Bearer ;
`login`/`signup` exposent bien le jeton dans le corps.

Refs #39
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 02:47:57 +02:00

90 lines
3.9 KiB
TypeScript

import { HttpError } from "@batch-cooking/error-tools";
import { ErrorCode, type SafeUserProfile } from "@batch-cooking/shared";
import type { NextFunction, Request, Response } from "express";
import { env } from "../config/env.js";
import { prisma } from "../db/prisma.js";
import { verifyAuthToken } from "../lib/jwt.js";
import { toSafeProfile } from "../lib/safe-profile.js";
/**
* Shape of `res.locals` once {@link requireAuth} has run successfully. Type
* a route handler's response as `Response<unknown, AuthLocals>` (see
* `auth.routes.ts`'s `/me` handler) to read `res.locals.userProfile` fully
* typed, no cast needed.
*/
export interface AuthLocals {
/** The authenticated profile, resolved from the session cookie's JWT (or an `Authorization: Bearer` one, for cookie-less clients). */
userProfile: SafeUserProfile;
}
/**
* Pulls the raw JWT out of an `Authorization: Bearer <jwt>` header, or
* returns `null` if it's absent or malformed. This is the fallback path for
* clients that can't rely on the `session` cookie — a Capacitor WebView,
* whose origin never receives a `SameSite=Lax` cookie (see `AuthResponse`
* in packages/shared). The cookie remains the primary mechanism; this only
* kicks in when the cookie isn't there.
*/
function readBearerToken(header: string | undefined): string | null {
if (!header?.startsWith("Bearer ")) {
return null;
}
const token = header.slice("Bearer ".length).trim();
return token.length > 0 ? token : null;
}
/**
* Express middleware guarding routes that require an authenticated
* profile. Reads the session cookie (falling back to an `Authorization:
* Bearer <jwt>` header when there's no cookie — see {@link readBearerToken}),
* verifies the JWT, and re-checks `tokenVersion` against the database — so a
* stateless JWT can still be invalidated server-side (e.g. on password
* change / logout-everywhere, once that feature exists) despite carrying no
* server-side session.
*
* On success, attaches the resolved profile to `res.locals.userProfile`
* (typed via {@link AuthLocals}) for downstream handlers to use.
* Deliberately `res.locals` rather than augmenting Express's global
* `Request` type via `declare global`: `res.locals` is Express's own
* built-in mechanism for exactly this (passing data from a middleware to
* the next handler), typed per-route through a generic parameter — no
* project-wide ambient augmentation silently changing every `Request` in
* the codebase, whether or not it went through this middleware.
*
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — no cookie
* and no usable Bearer header, malformed/expired JWT, unknown profile, or
* stale tokenVersion. Never distinguishes the reason to the client.
*/
export async function requireAuth(
req: Request,
res: Response<unknown, AuthLocals>,
next: NextFunction,
) {
try {
const cookieToken = req.cookies?.[env.AUTH_COOKIE_NAME];
// Cookie first (the browser's mechanism, unchanged); `Authorization:
// Bearer` as a fallback for cookie-less clients — see readBearerToken.
const token =
typeof cookieToken === "string" ? cookieToken : readBearerToken(req.headers.authorization);
if (typeof token !== "string") {
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
}
const payload = verifyAuthToken(token);
const profile = await prisma.userProfile.findUnique({ where: { id: payload.userProfileId } });
if (!profile || profile.tokenVersion !== payload.tokenVersion) {
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
}
res.locals.userProfile = toSafeProfile(profile);
next();
} catch (err) {
if (err instanceof HttpError) {
next(err);
} else {
// Covers jwt.verify failures (expired/invalid/malformed token).
next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
}
}
}