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>
90 lines
3.9 KiB
TypeScript
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"));
|
|
}
|
|
}
|
|
}
|