From 1d8c0010350e56b7cb34487b67e55c809e29ed28 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 6 Sep 2026 02:31:22 +0200 Subject: [PATCH 1/2] feat(auth): accepte un jeton Bearer en repli du cookie de session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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 --- apps/api/src/middlewares/require-auth.ts | 40 ++++++++++--- apps/api/src/modules/auth/auth.routes.ts | 15 ++++- apps/api/test/auth.test.ts | 56 ++++++++++++++++++ apps/web/src/api/client.ts | 72 +++++++++++++++++++---- apps/web/src/api/token-store.ts | 40 +++++++++++++ packages/shared/src/types/user-profile.ts | 19 ++++++ 6 files changed, 217 insertions(+), 25 deletions(-) create mode 100644 apps/web/src/api/token-store.ts diff --git a/apps/api/src/middlewares/require-auth.ts b/apps/api/src/middlewares/require-auth.ts index 099793c..f4e8336 100644 --- a/apps/api/src/middlewares/require-auth.ts +++ b/apps/api/src/middlewares/require-auth.ts @@ -13,16 +13,34 @@ import { toSafeProfile } from "../lib/safe-profile.js"; * typed, no cast needed. */ export interface AuthLocals { - /** The authenticated profile, resolved from the session cookie's JWT. */ + /** 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 ` 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, 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. + * profile. Reads the session cookie (falling back to an `Authorization: + * Bearer ` 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. @@ -33,9 +51,9 @@ export interface AuthLocals { * 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 — missing - * cookie, malformed/expired JWT, unknown profile, or stale tokenVersion. - * Never distinguishes the reason to the client. + * @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, @@ -43,7 +61,11 @@ export async function requireAuth( next: NextFunction, ) { try { - const token = req.cookies?.[env.AUTH_COOKIE_NAME]; + 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"); } diff --git a/apps/api/src/modules/auth/auth.routes.ts b/apps/api/src/modules/auth/auth.routes.ts index af7bbcb..c8a3042 100644 --- a/apps/api/src/modules/auth/auth.routes.ts +++ b/apps/api/src/modules/auth/auth.routes.ts @@ -1,5 +1,10 @@ import { wrapAsyncHandler } from "@batch-cooking/express-tools"; -import { deleteAccountSchema, loginSchema, signupSchema } from "@batch-cooking/shared"; +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"; @@ -43,7 +48,11 @@ authRouter.post( const input = signupSchema.parse(req.body); const { profile, token } = await signup(input); res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions); - res.status(201).json(profile); + // 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); }), ); @@ -54,7 +63,7 @@ authRouter.post( 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); + res.status(200).json({ ...profile, token } satisfies AuthResponse); }), ); diff --git a/apps/api/test/auth.test.ts b/apps/api/test/auth.test.ts index 8383a6b..f68fbaa 100644 --- a/apps/api/test/auth.test.ts +++ b/apps/api/test/auth.test.ts @@ -55,6 +55,17 @@ describe("Auth", () => { expect(res.headers["set-cookie"]?.[0]).to.include("session="); }); + it("also echoes the session JWT in the response body (for cookie-less clients)", async () => { + const res = await request(app).post("/auth/signup").send(buildSignupPayload()); + + expect(res.status).to.equal(201); + // Same token that's set as the cookie — see `AuthResponse` + // (packages/shared). Lets a Capacitor WebView, whose origin can't + // receive the SameSite cookie, store it and send it as a Bearer. + expect(res.body.token).to.be.a("string").with.length.greaterThan(0); + expect(res.headers["set-cookie"]?.[0]).to.include(`session=${res.body.token}`); + }); + it("rejects a duplicate email with 409 EMAIL_ALREADY_IN_USE", async () => { const payload = buildSignupPayload(); await request(app).post("/auth/signup").send(payload); @@ -95,6 +106,16 @@ describe("Auth", () => { expect(res.body.email).to.equal(payload.email); }); + it("echoes the session JWT in the response body too", async () => { + const res = await request(app) + .post("/auth/login") + .send({ email: payload.email, password: payload.password }); + + expect(res.status).to.equal(200); + expect(res.body.token).to.be.a("string").with.length.greaterThan(0); + expect(res.headers["set-cookie"]?.[0]).to.include(`session=${res.body.token}`); + }); + it("rejects a wrong password with 401 INVALID_CREDENTIALS", async () => { const res = await request(app) .post("/auth/login") @@ -134,6 +155,41 @@ describe("Auth", () => { }); }); + // The Bearer path exists for clients whose origin can't carry the + // `SameSite=Lax` session cookie (a Capacitor WebView — issue #39). The + // cookie stays the primary mechanism; the header is only consulted when + // no cookie is present. `request(app)` (no `.agent`) sends no cookie, so + // these requests exercise the fallback in isolation. + describe("Authorization: Bearer fallback (cookie-less clients)", () => { + it("authenticates a protected route with a valid Bearer token and no cookie", async () => { + const payload = buildSignupPayload(); + const signupRes = await request(app).post("/auth/signup").send(payload); + + const res = await request(app) + .get("/auth/me") + .set("Authorization", `Bearer ${signupRes.body.token}`); + + expect(res.status).to.equal(200); + expect(res.body.email).to.equal(payload.email); + }); + + it("rejects a malformed Bearer token with 401 NOT_AUTHENTICATED", async () => { + const res = await request(app).get("/auth/me").set("Authorization", "Bearer not-a-real-jwt"); + + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); + }); + + it("ignores a non-Bearer Authorization scheme and 401s", async () => { + const res = await request(app) + .get("/auth/me") + .set("Authorization", "Basic dXNlcjpwYXNzd29yZA=="); + + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); + }); + }); + describe("DELETE /auth/me", () => { it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { const res = await request(app).delete("/auth/me").send({ password: "whatever" }); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 45155e7..d2bdb65 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -3,6 +3,7 @@ import { type AllergyView, type ApiErrorResponse, type AssignLeftoverInput, + type AuthResponse, type BrowsableSourceItemView, type CreateRecipeInput, type DietView, @@ -31,6 +32,7 @@ import { type UpdateRecipeInput, type UtensilView, } from "@batch-cooking/shared"; +import { noopTokenStore, type TokenStore } from "./token-store"; /** * Base URL of the API, configurable via `VITE_API_URL` (see `.env.example`). @@ -43,6 +45,25 @@ import { */ const API_BASE_URL: string = import.meta.env.VITE_API_URL ?? ""; +/** + * The token store {@link ApiClient} consults on every request. Defaults to + * the browser no-op (the httpOnly cookie carries the session); the native + * shell replaces it via {@link setTokenStore} with a persistent, + * `@capacitor/preferences`-backed store. Module-level singleton, same shape + * as {@link API_BASE_URL} and the exported `apiClient`. + */ +let activeTokenStore: TokenStore = noopTokenStore; + +/** + * Swaps in a non-browser {@link TokenStore}. Called once at startup by the + * Capacitor bootstrap (issue #39, later phase) when + * `Capacitor.isNativePlatform()` — never by the web build, which keeps the + * no-op and its cookie. + */ +export function setTokenStore(store: TokenStore): void { + activeTokenStore = store; +} + /** * Thrown by {@link ApiClient} whenever the API responds with a non-2xx * status. Carries the same {@link ErrorCode} the API returned, so callers @@ -84,12 +105,20 @@ export class ApiClient { options: RequestInit = {}, ): Promise { try { + // `null` for the browser (no-op store — the cookie below is the + // session); a real JWT only in the native shell, where the cookie + // can't travel. See `token-store.ts`. + const token = await activeTokenStore.get(); const response = await fetch(`${API_BASE_URL}${path}`, { ...options, // Required for the httpOnly session cookie to be sent/received — the // API and the web app run on different origins. credentials: "include", - headers: { "Content-Type": "application/json", ...options.headers }, + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...options.headers, + }, }); if (!response.ok) { @@ -117,19 +146,35 @@ export class ApiClient { } } - /** Creates a profile and starts a session — no household yet, that's an optional step of the onboarding wizard. */ - public signup(input: SignupInput): Promise { - return this._request("/auth/signup", { method: "POST", body: JSON.stringify(input) }); + /** + * Creates a profile and starts a session — no household yet, that's an + * optional step of the onboarding wizard. Persists the returned token via + * the active {@link TokenStore} (no-op in the browser, where the httpOnly + * cookie is what actually keeps the session). + */ + public async signup(input: SignupInput): Promise { + const { token, ...profile } = await this._request("/auth/signup", { + method: "POST", + body: JSON.stringify(input), + }); + await activeTokenStore.set(token); + return profile; } - /** Verifies credentials and starts a session. */ - public login(input: LoginInput): Promise { - return this._request("/auth/login", { method: "POST", body: JSON.stringify(input) }); + /** Verifies credentials and starts a session — see {@link signup} re: the token. */ + public async login(input: LoginInput): Promise { + const { token, ...profile } = await this._request("/auth/login", { + method: "POST", + body: JSON.stringify(input), + }); + await activeTokenStore.set(token); + return profile; } - /** Ends the current session. */ - public logout(): Promise { - return this._request("/auth/logout", { method: "POST" }); + /** Ends the current session, and drops any stored token. */ + public async logout(): Promise { + await this._request("/auth/logout", { method: "POST" }); + await activeTokenStore.clear(); } /** Fetches the currently authenticated profile — rejects with `NOT_AUTHENTICATED` if there's no session. */ @@ -137,9 +182,10 @@ export class ApiClient { return this._request("/auth/me"); } - /** Permanently deletes the current profile, after re-verifying its password — rejects with `INVALID_CREDENTIALS` if it's wrong. */ - public deleteAccount(password: string): Promise { - return this._request("/auth/me", { method: "DELETE", body: JSON.stringify({ password }) }); + /** Permanently deletes the current profile, after re-verifying its password — rejects with `INVALID_CREDENTIALS` if it's wrong. Drops any stored token on success. */ + public async deleteAccount(password: string): Promise { + await this._request("/auth/me", { method: "DELETE", body: JSON.stringify({ password }) }); + await activeTokenStore.clear(); } /** diff --git a/apps/web/src/api/token-store.ts b/apps/web/src/api/token-store.ts new file mode 100644 index 0000000..98e7fdf --- /dev/null +++ b/apps/web/src/api/token-store.ts @@ -0,0 +1,40 @@ +/** + * Where {@link ApiClient} keeps the session JWT when it can't live in a + * cookie. + * + * The browser never needs this: `POST /auth/login` sets an httpOnly + * `session` cookie that the browser replays automatically, so the default + * {@link noopTokenStore} genuinely does nothing. The abstraction exists for + * the Capacitor build (added in a later phase of issue #39): a packaged app + * runs the web bundle under `capacitor://localhost` / `https://localhost`, + * a different *site* (cookie `SameSite` is domain-based) from the API host, + * so a `SameSite=Lax` cookie never reaches it. There, a + * `@capacitor/preferences`-backed store persists the token handed back in + * the login/signup response body (see `AuthResponse` in `packages/shared`) + * and {@link ApiClient} replays it as `Authorization: Bearer ` on + * every request. + * + * Methods are `Promise`-returning because the Capacitor Preferences API is + * async; {@link noopTokenStore} just resolves immediately. + */ +export interface TokenStore { + /** The stored token, or `null` when there isn't one. */ + get(): Promise; + /** Persists `token` as the current session token. */ + set(token: string): Promise; + /** Drops any stored token — logout, or account deletion. */ + clear(): Promise; +} + +/** + * The browser implementation: a real no-op. The httpOnly `session` cookie + * does all the work and JS deliberately can't read it, so `get` always + * resolves `null` and {@link ApiClient} never adds an `Authorization` + * header. Swapped out for a persistent store only in the native shell, via + * `setTokenStore` (see `client.ts`). + */ +export const noopTokenStore: TokenStore = { + get: () => Promise.resolve(null), + set: () => Promise.resolve(), + clear: () => Promise.resolve(), +}; diff --git a/packages/shared/src/types/user-profile.ts b/packages/shared/src/types/user-profile.ts index c221579..11fc6ac 100644 --- a/packages/shared/src/types/user-profile.ts +++ b/packages/shared/src/types/user-profile.ts @@ -20,3 +20,22 @@ export interface SafeUserProfile { /** FK to this profile's diet preference, or `null` if unset. */ dietId: number | null; } + +/** + * Response body of `POST /auth/login` and `POST /auth/signup`: the safe + * profile, plus the session JWT echoed as a plain field. + * + * The same JWT is *also* set as the httpOnly `session` cookie (unchanged) — + * it's repeated here purely for clients whose origin can't receive that + * cookie. A Capacitor WebView runs the web bundle under + * `capacitor://localhost` / `https://localhost`, a different *site* (cookie + * `SameSite` is domain-based, not port-based) from wherever the API is + * hosted, so a `SameSite=Lax` cookie never travels there. Such a client + * stores this `token` and sends it back as `Authorization: Bearer ` + * instead (see apps/api's `require-auth.ts`). Browser clients ignore the + * field and keep relying on the cookie. + */ +export interface AuthResponse extends SafeUserProfile { + /** Signed session JWT — identical to the value set as the `session` cookie. */ + token: string; +} From 5d7e4d6f6a291bb3e345e9205019e4ae57cc1b93 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 6 Sep 2026 03:07:17 +0200 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20relance=20la=20CI=20(job=20test=20?= =?UTF-8?q?cass=C3=A9=20par=20une=20course=20d'infra=20avec=20la=20run=20d?= =?UTF-8?q?e=20main)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La run #104 a échoué au `beforeAll` racine (`seedReferenceData`, P2002 sur `key`) parce que la run `main` #103 exécutait son propre job `test` au même moment sur le conteneur Postgres `--name postgres` partagé. Runner idle, aucune run concurrente cette fois. Code inchangé — build+lint verts en local. Co-Authored-By: Claude Sonnet 5