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>
91 lines
3.7 KiB
TypeScript
91 lines
3.7 KiB
TypeScript
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
|
import {
|
|
type AuthResponse,
|
|
deleteAccountSchema,
|
|
loginSchema,
|
|
signupSchema,
|
|
} from "@batch-cooking/shared";
|
|
import type { CookieOptions, Response } from "express";
|
|
import { Router } from "express";
|
|
import { env } from "../../config/env.js";
|
|
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
|
import { deleteAccount, login, signup } from "./auth.service.js";
|
|
|
|
/** Router mounted at `/auth` in app.ts — signup, login, logout, current-profile. */
|
|
export const authRouter = Router();
|
|
|
|
// Deliberately independent from JWT_EXPIRES_IN: the JWT's own expiry is
|
|
// what's actually enforced by requireAuth (a request with an expired JWT
|
|
// is rejected regardless of the cookie still being present) — this only
|
|
// bounds how long the browser keeps *sending* the cookie at all.
|
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
|
/** Cookie options shared by every route that sets the session cookie. */
|
|
const cookieOptions: CookieOptions = {
|
|
httpOnly: true,
|
|
// Defaults to requiring HTTPS in production, but overridable via
|
|
// COOKIE_SECURE — see its doc comment in config/env.ts for why this
|
|
// can't just be `NODE_ENV === "production"`.
|
|
secure: env.COOKIE_SECURE ?? env.NODE_ENV === "production",
|
|
sameSite: "lax",
|
|
maxAge: SEVEN_DAYS_MS,
|
|
};
|
|
|
|
// `res.clearCookie` sets its own expiry to clear the cookie — passing
|
|
// `maxAge` alongside is deprecated (and pointless) as of Express 4.20, so
|
|
// every clearing route reuses `cookieOptions` minus that one field rather
|
|
// than duplicating the rest by hand.
|
|
const { maxAge: _maxAge, ...clearCookieOptions } = cookieOptions;
|
|
|
|
/**
|
|
* Creates a profile (+ its household) and logs the new user in
|
|
* immediately. `wrapAsyncHandler` forwards a thrown/rejected error to
|
|
* Express's error middleware automatically — no manual try/catch needed.
|
|
*/
|
|
authRouter.post(
|
|
"/signup",
|
|
wrapAsyncHandler(async (req, res) => {
|
|
const input = signupSchema.parse(req.body);
|
|
const { profile, token } = await signup(input);
|
|
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
|
|
// Token is echoed in the body too, not just the cookie — see
|
|
// `AuthResponse` (packages/shared) for why (Capacitor WebView origin
|
|
// can't receive a SameSite cookie). Purely additive: every existing
|
|
// profile field stays exactly where it was.
|
|
res.status(201).json({ ...profile, token } satisfies AuthResponse);
|
|
}),
|
|
);
|
|
|
|
/** Verifies credentials and starts a new session. */
|
|
authRouter.post(
|
|
"/login",
|
|
wrapAsyncHandler(async (req, res) => {
|
|
const input = loginSchema.parse(req.body);
|
|
const { profile, token } = await login(input);
|
|
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
|
|
res.status(200).json({ ...profile, token } satisfies AuthResponse);
|
|
}),
|
|
);
|
|
|
|
/** Ends the current session by clearing the cookie. Stateless JWT, so there's nothing to revoke server-side (yet — see tokenVersion). */
|
|
authRouter.post("/logout", (_req, res) => {
|
|
res.clearCookie(env.AUTH_COOKIE_NAME, clearCookieOptions);
|
|
res.status(204).end();
|
|
});
|
|
|
|
/** Returns the currently authenticated profile. Behind requireAuth — 401s if there's no valid session. */
|
|
authRouter.get("/me", requireAuth, (_req, res: Response<unknown, AuthLocals>) => {
|
|
res.status(200).json(res.locals.userProfile);
|
|
});
|
|
|
|
/** Permanently deletes the current profile (re-verifying its password first) and clears the session cookie. */
|
|
authRouter.delete(
|
|
"/me",
|
|
requireAuth,
|
|
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
|
const input = deleteAccountSchema.parse(req.body);
|
|
await deleteAccount(res.locals.userProfile.id, input.password);
|
|
res.clearCookie(env.AUTH_COOKIE_NAME, clearCookieOptions);
|
|
res.status(204).end();
|
|
}),
|
|
);
|